mirror of
https://github.com/immich-app/immich.git
synced 2025-12-09 09:13:08 +03:00
Compare commits
9 Commits
06f0f8dc48
...
push-nklmv
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74c107284b | ||
|
|
1109c32891 | ||
|
|
3c80049192 | ||
|
|
8f1669efbe | ||
|
|
146bf65d02 | ||
|
|
75a7c9c06c | ||
|
|
ae8f5a6673 | ||
|
|
31f2c7b505 | ||
|
|
ba6687dde9 |
2
.github/package.json
vendored
2
.github/package.json
vendored
@@ -4,6 +4,6 @@
|
||||
"format:fix": "prettier --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "^3.5.3"
|
||||
"prettier": "^3.7.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"eslint-plugin-unicorn": "^62.0.0",
|
||||
"globals": "^16.0.0",
|
||||
"mock-fs": "^5.2.0",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier-plugin-organize-imports": "^4.0.0",
|
||||
"typescript": "^5.3.3",
|
||||
"typescript-eslint": "^8.28.0",
|
||||
|
||||
@@ -58,10 +58,6 @@ services:
|
||||
IMMICH_THIRD_PARTY_BUG_FEATURE_URL: https://github.com/immich-app/immich/issues
|
||||
IMMICH_THIRD_PARTY_DOCUMENTATION_URL: https://docs.immich.app
|
||||
IMMICH_THIRD_PARTY_SUPPORT_URL: https://docs.immich.app/community-guides
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 1048576
|
||||
hard: 1048576
|
||||
ports:
|
||||
- 9230:9230
|
||||
- 9231:9231
|
||||
@@ -100,10 +96,6 @@ services:
|
||||
- app-node_modules:/usr/src/app/node_modules
|
||||
- sveltekit:/usr/src/app/web/.svelte-kit
|
||||
- coverage:/usr/src/app/web/coverage
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 1048576
|
||||
hard: 1048576
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
immich-server:
|
||||
|
||||
@@ -32,8 +32,6 @@ server {
|
||||
|
||||
# enable websockets: http://nginx.org/en/docs/http/websocket.html
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_redirect off;
|
||||
|
||||
# set timeout
|
||||
@@ -43,6 +41,8 @@ server {
|
||||
|
||||
location / {
|
||||
proxy_pass http://<backend_url>:2283;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
# useful when using Let's Encrypt http-01 challenge
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"@docusaurus/module-type-aliases": "~3.9.0",
|
||||
"@docusaurus/tsconfig": "^3.7.0",
|
||||
"@docusaurus/types": "^3.7.0",
|
||||
"prettier": "^3.2.4",
|
||||
"prettier": "^3.7.4",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"browserslist": {
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"oidc-provider": "^9.0.0",
|
||||
"pg": "^8.11.3",
|
||||
"pngjs": "^7.0.0",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier-plugin-organize-imports": "^4.0.0",
|
||||
"sharp": "^0.34.5",
|
||||
"socket.io-client": "^4.7.4",
|
||||
|
||||
@@ -78,7 +78,6 @@
|
||||
"exclusion_pattern_description": "Exclusion patterns lets you ignore files and folders when scanning your library. This is useful if you have folders that contain files you don't want to import, such as RAW files.",
|
||||
"export_config_as_json_description": "Download the current system config as a JSON file",
|
||||
"external_libraries_page_description": "Admin external library page",
|
||||
"external_library_management": "External Library Management",
|
||||
"face_detection": "Face detection",
|
||||
"face_detection_description": "Detect the faces in assets using machine learning. For videos, only the thumbnail is considered. \"Refresh\" (re-)processes all assets. \"Reset\" additionally clears all current face data. \"Missing\" queues assets that haven't been processed yet. Detected faces will be queued for Facial Recognition after Face Detection is complete, grouping them into existing or new people.",
|
||||
"facial_recognition_job_description": "Group detected faces into people. This step runs after Face Detection is complete. \"Reset\" (re-)clusters all faces. \"Missing\" queues faces that don't have a person assigned.",
|
||||
|
||||
@@ -37,7 +37,7 @@ class DriftActivitiesPage extends HookConsumerWidget {
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(album.name),
|
||||
actions: [const LikeActivityActionButton(menuItem: true)],
|
||||
actions: [const LikeActivityActionButton(iconOnly: true)],
|
||||
actionsPadding: const EdgeInsets.only(right: 8),
|
||||
),
|
||||
body: activities.widgetWhen(
|
||||
|
||||
@@ -21,12 +21,34 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_shee
|
||||
|
||||
enum AddToMenuItem { album, archive, unarchive, lockedFolder }
|
||||
|
||||
class AddActionButton extends ConsumerWidget {
|
||||
class AddActionButton extends ConsumerStatefulWidget {
|
||||
const AddActionButton({super.key});
|
||||
|
||||
Future<void> _showAddOptions(BuildContext context, WidgetRef ref) async {
|
||||
@override
|
||||
ConsumerState<AddActionButton> createState() => _AddActionButtonState();
|
||||
}
|
||||
|
||||
class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
||||
void _handleMenuSelection(AddToMenuItem selected) {
|
||||
switch (selected) {
|
||||
case AddToMenuItem.album:
|
||||
_openAlbumSelector();
|
||||
break;
|
||||
case AddToMenuItem.archive:
|
||||
performArchiveAction(context, ref, source: ActionSource.viewer);
|
||||
break;
|
||||
case AddToMenuItem.unarchive:
|
||||
performUnArchiveAction(context, ref, source: ActionSource.viewer);
|
||||
break;
|
||||
case AddToMenuItem.lockedFolder:
|
||||
performMoveToLockFolderAction(context, ref, source: ActionSource.viewer);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
List<Widget> _buildMenuChildren() {
|
||||
final asset = ref.read(currentAssetNotifier);
|
||||
if (asset == null) return;
|
||||
if (asset == null) return [];
|
||||
|
||||
final user = ref.read(currentUserProvider);
|
||||
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
|
||||
@@ -35,93 +57,57 @@ class AddActionButton extends ConsumerWidget {
|
||||
final hasRemote = asset is RemoteAsset;
|
||||
final showArchive = isOwner && !isInLockedView && hasRemote && !isArchived;
|
||||
final showUnarchive = isOwner && !isInLockedView && hasRemote && isArchived;
|
||||
final menuItemHeight = 30.0;
|
||||
|
||||
final List<PopupMenuEntry<AddToMenuItem>> items = [
|
||||
PopupMenuItem(
|
||||
enabled: false,
|
||||
textStyle: context.textTheme.labelMedium,
|
||||
height: 40,
|
||||
child: Text("add_to_bottom_bar".tr()),
|
||||
return [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text("add_to_bottom_bar".tr(), style: context.textTheme.labelMedium),
|
||||
),
|
||||
PopupMenuItem(
|
||||
height: menuItemHeight,
|
||||
value: AddToMenuItem.album,
|
||||
child: ListTile(leading: const Icon(Icons.photo_album_outlined), title: Text("album".tr())),
|
||||
BaseActionButton(
|
||||
iconData: Icons.photo_album_outlined,
|
||||
label: "album".tr(),
|
||||
menuItem: true,
|
||||
onPressed: () => _handleMenuSelection(AddToMenuItem.album),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
PopupMenuItem(enabled: false, textStyle: context.textTheme.labelMedium, height: 40, child: Text("move_to".tr())),
|
||||
|
||||
if (isOwner) ...[
|
||||
const PopupMenuDivider(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text("move_to".tr(), style: context.textTheme.labelMedium),
|
||||
),
|
||||
if (showArchive)
|
||||
PopupMenuItem(
|
||||
height: menuItemHeight,
|
||||
value: AddToMenuItem.archive,
|
||||
child: ListTile(leading: const Icon(Icons.archive_outlined), title: Text("archive".tr())),
|
||||
BaseActionButton(
|
||||
iconData: Icons.archive_outlined,
|
||||
label: "archive".tr(),
|
||||
menuItem: true,
|
||||
onPressed: () => _handleMenuSelection(AddToMenuItem.archive),
|
||||
),
|
||||
if (showUnarchive)
|
||||
PopupMenuItem(
|
||||
height: menuItemHeight,
|
||||
value: AddToMenuItem.unarchive,
|
||||
child: ListTile(leading: const Icon(Icons.unarchive_outlined), title: Text("unarchive".tr())),
|
||||
BaseActionButton(
|
||||
iconData: Icons.unarchive_outlined,
|
||||
label: "unarchive".tr(),
|
||||
menuItem: true,
|
||||
onPressed: () => _handleMenuSelection(AddToMenuItem.unarchive),
|
||||
),
|
||||
PopupMenuItem(
|
||||
height: menuItemHeight,
|
||||
value: AddToMenuItem.lockedFolder,
|
||||
child: ListTile(leading: const Icon(Icons.lock_outline), title: Text("locked_folder".tr())),
|
||||
BaseActionButton(
|
||||
iconData: Icons.lock_outline,
|
||||
label: "locked_folder".tr(),
|
||||
menuItem: true,
|
||||
onPressed: () => _handleMenuSelection(AddToMenuItem.lockedFolder),
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
final AddToMenuItem? selected = await showMenu<AddToMenuItem>(
|
||||
context: context,
|
||||
color: context.themeData.scaffoldBackgroundColor,
|
||||
position: _menuPosition(context),
|
||||
items: items,
|
||||
popUpAnimationStyle: AnimationStyle.noAnimation,
|
||||
);
|
||||
|
||||
if (selected == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (selected) {
|
||||
case AddToMenuItem.album:
|
||||
_openAlbumSelector(context, ref);
|
||||
break;
|
||||
case AddToMenuItem.archive:
|
||||
await performArchiveAction(context, ref, source: ActionSource.viewer);
|
||||
break;
|
||||
case AddToMenuItem.unarchive:
|
||||
await performUnArchiveAction(context, ref, source: ActionSource.viewer);
|
||||
break;
|
||||
case AddToMenuItem.lockedFolder:
|
||||
await performMoveToLockFolderAction(context, ref, source: ActionSource.viewer);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
RelativeRect _menuPosition(BuildContext context) {
|
||||
final renderObject = context.findRenderObject();
|
||||
if (renderObject is! RenderBox) {
|
||||
return RelativeRect.fill;
|
||||
}
|
||||
|
||||
final size = renderObject.size;
|
||||
final position = renderObject.localToGlobal(Offset.zero);
|
||||
|
||||
return RelativeRect.fromLTRB(position.dx, position.dy - size.height - 200, position.dx + size.width, position.dy);
|
||||
}
|
||||
|
||||
void _openAlbumSelector(BuildContext context, WidgetRef ref) {
|
||||
void _openAlbumSelector() {
|
||||
final currentAsset = ref.read(currentAssetNotifier);
|
||||
if (currentAsset == null) {
|
||||
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
|
||||
return;
|
||||
}
|
||||
|
||||
final List<Widget> slivers = [
|
||||
AlbumSelector(onAlbumSelected: (album) => _addCurrentAssetToAlbum(context, ref, album)),
|
||||
];
|
||||
final List<Widget> slivers = [AlbumSelector(onAlbumSelected: (album) => _addCurrentAssetToAlbum(album))];
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
@@ -141,7 +127,7 @@ class AddActionButton extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _addCurrentAssetToAlbum(BuildContext context, WidgetRef ref, RemoteAlbum album) async {
|
||||
Future<void> _addCurrentAssetToAlbum(RemoteAlbum album) async {
|
||||
final latest = ref.read(currentAssetNotifier);
|
||||
|
||||
if (latest == null) {
|
||||
@@ -174,17 +160,27 @@ class AddActionButton extends ConsumerWidget {
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
Widget build(BuildContext context) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
if (asset == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Builder(
|
||||
builder: (buttonContext) {
|
||||
|
||||
return MenuAnchor(
|
||||
consumeOutsideTap: true,
|
||||
style: MenuStyle(
|
||||
backgroundColor: WidgetStatePropertyAll(context.themeData.scaffoldBackgroundColor),
|
||||
elevation: const WidgetStatePropertyAll(4),
|
||||
shape: const WidgetStatePropertyAll(
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
|
||||
),
|
||||
),
|
||||
menuChildren: _buildMenuChildren(),
|
||||
builder: (context, controller, child) {
|
||||
return BaseActionButton(
|
||||
iconData: Icons.add,
|
||||
label: "add_to_bottom_bar".tr(),
|
||||
onPressed: () => _showAddOptions(buttonContext, ref),
|
||||
onPressed: () => controller.isOpen ? controller.close() : controller.open(),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ class BaseActionButton extends StatelessWidget {
|
||||
this.onLongPressed,
|
||||
this.maxWidth = 90.0,
|
||||
this.minWidth,
|
||||
this.iconOnly = false,
|
||||
this.menuItem = false,
|
||||
});
|
||||
|
||||
@@ -19,6 +20,11 @@ class BaseActionButton extends StatelessWidget {
|
||||
final Color? iconColor;
|
||||
final double maxWidth;
|
||||
final double? minWidth;
|
||||
|
||||
/// When true, renders only an IconButton without text label
|
||||
final bool iconOnly;
|
||||
|
||||
/// When true, renders as a MenuItemButton for use in MenuAnchor menus
|
||||
final bool menuItem;
|
||||
final void Function()? onPressed;
|
||||
final void Function()? onLongPressed;
|
||||
@@ -31,13 +37,26 @@ class BaseActionButton extends StatelessWidget {
|
||||
final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color;
|
||||
final textColor = context.themeData.textTheme.labelLarge?.color;
|
||||
|
||||
if (menuItem) {
|
||||
if (iconOnly) {
|
||||
return IconButton(
|
||||
onPressed: onPressed,
|
||||
icon: Icon(iconData, size: iconSize, color: iconColor),
|
||||
);
|
||||
}
|
||||
|
||||
if (menuItem) {
|
||||
final theme = context.themeData;
|
||||
final effectiveStyle = theme.textTheme.labelLarge;
|
||||
final effectiveIconColor = iconColor ?? theme.iconTheme.color ?? theme.colorScheme.onSurfaceVariant;
|
||||
|
||||
return MenuItemButton(
|
||||
style: MenuItemButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12)),
|
||||
leadingIcon: Icon(iconData, color: effectiveIconColor, size: 20),
|
||||
onPressed: onPressed,
|
||||
child: Text(label, style: effectiveStyle),
|
||||
);
|
||||
}
|
||||
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||
child: MaterialButton(
|
||||
|
||||
@@ -7,8 +7,9 @@ import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart';
|
||||
|
||||
class CastActionButton extends ConsumerWidget {
|
||||
const CastActionButton({super.key, this.menuItem = true});
|
||||
const CastActionButton({super.key, this.iconOnly = true, this.menuItem = false});
|
||||
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
|
||||
@override
|
||||
@@ -22,6 +23,7 @@ class CastActionButton extends ConsumerWidget {
|
||||
onPressed: () {
|
||||
showDialog(context: context, builder: (context) => const CastDialog());
|
||||
},
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,8 +10,9 @@ import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
|
||||
class DownloadActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
const DownloadActionButton({super.key, required this.source, this.menuItem = false});
|
||||
const DownloadActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref, BackgroundSyncManager backgroundSyncManager) async {
|
||||
if (!context.mounted) {
|
||||
@@ -38,6 +39,7 @@ class DownloadActionButton extends ConsumerWidget {
|
||||
iconData: Icons.download,
|
||||
maxWidth: 95,
|
||||
label: "download".t(context: context),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context, ref, backgroundManager),
|
||||
);
|
||||
|
||||
@@ -10,9 +10,10 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class FavoriteActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
|
||||
const FavoriteActionButton({super.key, required this.source, this.menuItem = false});
|
||||
const FavoriteActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
@@ -44,6 +45,7 @@ class FavoriteActionButton extends ConsumerWidget {
|
||||
return BaseActionButton(
|
||||
iconData: Icons.favorite_border_rounded,
|
||||
label: "favorite".t(context: context),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context, ref),
|
||||
);
|
||||
|
||||
@@ -12,8 +12,9 @@ import 'package:immich_mobile/providers/infrastructure/current_album.provider.da
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
|
||||
class LikeActivityActionButton extends ConsumerWidget {
|
||||
const LikeActivityActionButton({super.key, this.menuItem = false});
|
||||
const LikeActivityActionButton({super.key, this.iconOnly = false, this.menuItem = false});
|
||||
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
|
||||
@override
|
||||
@@ -49,6 +50,7 @@ class LikeActivityActionButton extends ConsumerWidget {
|
||||
iconData: liked != null ? Icons.favorite : Icons.favorite_border,
|
||||
label: "like".t(context: context),
|
||||
onPressed: () => onTap(liked),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
);
|
||||
},
|
||||
@@ -57,6 +59,7 @@ class LikeActivityActionButton extends ConsumerWidget {
|
||||
loading: () => BaseActionButton(
|
||||
iconData: Icons.favorite_border,
|
||||
label: "like".t(context: context),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
),
|
||||
error: (error, stack) => Text('error_saving_image'.tr(args: [error.toString()])),
|
||||
|
||||
@@ -5,8 +5,9 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_bu
|
||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
||||
|
||||
class MotionPhotoActionButton extends ConsumerWidget {
|
||||
const MotionPhotoActionButton({super.key, this.menuItem = true});
|
||||
const MotionPhotoActionButton({super.key, this.iconOnly = true, this.menuItem = false});
|
||||
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
|
||||
@override
|
||||
@@ -17,6 +18,7 @@ class MotionPhotoActionButton extends ConsumerWidget {
|
||||
iconData: isPlaying ? Icons.motion_photos_pause_outlined : Icons.play_circle_outline_rounded,
|
||||
label: "play_motion_photo".t(context: context),
|
||||
onPressed: ref.read(isPlayingMotionVideoProvider.notifier).toggle,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,9 +10,10 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class UnFavoriteActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
|
||||
const UnFavoriteActionButton({super.key, required this.source, this.menuItem = false});
|
||||
const UnFavoriteActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
@@ -45,6 +46,7 @@ class UnFavoriteActionButton extends ConsumerWidget {
|
||||
iconData: Icons.favorite_rounded,
|
||||
label: "unfavorite".t(context: context),
|
||||
onPressed: () => _onTap(context, ref),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_actio
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart';
|
||||
import 'package:immich_mobile/providers/activity.provider.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
@@ -65,8 +66,8 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
||||
|
||||
final actions = <Widget>[
|
||||
if (asset.isRemoteOnly) const DownloadActionButton(source: ActionSource.viewer, menuItem: true),
|
||||
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
|
||||
if (asset.isRemoteOnly) const DownloadActionButton(source: ActionSource.viewer, iconOnly: true),
|
||||
if (isCasting || (asset.hasRemote)) const CastActionButton(iconOnly: true),
|
||||
if (album != null && album.isActivityEnabled && album.isShared)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chat_outlined),
|
||||
@@ -85,16 +86,16 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
tooltip: 'view_in_timeline'.t(context: context),
|
||||
),
|
||||
if (asset.hasRemote && isOwner && !asset.isFavorite)
|
||||
const FavoriteActionButton(source: ActionSource.viewer, menuItem: true),
|
||||
const FavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
|
||||
if (asset.hasRemote && isOwner && asset.isFavorite)
|
||||
const UnFavoriteActionButton(source: ActionSource.viewer, menuItem: true),
|
||||
if (asset.isMotionPhoto) const MotionPhotoActionButton(menuItem: true),
|
||||
const _KebabMenu(),
|
||||
const UnFavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
|
||||
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
|
||||
const ViewerKebabMenu(),
|
||||
];
|
||||
|
||||
final lockedViewActions = <Widget>[
|
||||
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
|
||||
const _KebabMenu(),
|
||||
if (isCasting || (asset.hasRemote)) const CastActionButton(iconOnly: true),
|
||||
const ViewerKebabMenu(),
|
||||
];
|
||||
|
||||
return IgnorePointer(
|
||||
@@ -122,20 +123,6 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
Size get preferredSize => const Size.fromHeight(60.0);
|
||||
}
|
||||
|
||||
class _KebabMenu extends ConsumerWidget {
|
||||
const _KebabMenu();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return IconButton(
|
||||
onPressed: () {
|
||||
EventStream.shared.emit(const ViewerOpenBottomSheetEvent());
|
||||
},
|
||||
icon: const Icon(Icons.more_vert_rounded),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AppBarBackButton extends ConsumerWidget {
|
||||
const _AppBarBackButton();
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.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/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
|
||||
class ViewerKebabMenu extends ConsumerWidget {
|
||||
const ViewerKebabMenu({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
if (asset == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final menuChildren = <Widget>[
|
||||
BaseActionButton(
|
||||
label: 'about'.tr(),
|
||||
iconData: Icons.info_outline,
|
||||
menuItem: true,
|
||||
onPressed: () => EventStream.shared.emit(const ViewerOpenBottomSheetEvent()),
|
||||
),
|
||||
];
|
||||
|
||||
return MenuAnchor(
|
||||
consumeOutsideTap: true,
|
||||
style: MenuStyle(
|
||||
backgroundColor: WidgetStatePropertyAll(context.themeData.scaffoldBackgroundColor),
|
||||
elevation: const WidgetStatePropertyAll(4),
|
||||
shape: const WidgetStatePropertyAll(
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
|
||||
),
|
||||
),
|
||||
menuChildren: menuChildren,
|
||||
builder: (context, controller, child) {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.more_vert_rounded),
|
||||
onPressed: () => controller.isOpen ? controller.close() : controller.open(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -167,7 +167,7 @@ class AppRouter extends RootStackRouter {
|
||||
AutoRoute(page: LoginRoute.page, guards: [_duplicateGuard]),
|
||||
AutoRoute(page: ChangePasswordRoute.page),
|
||||
AutoRoute(page: SearchRoute.page, guards: [_authGuard, _duplicateGuard], maintainState: false),
|
||||
CustomRoute(
|
||||
AutoRoute(
|
||||
page: TabControllerRoute.page,
|
||||
guards: [_authGuard, _duplicateGuard],
|
||||
children: [
|
||||
@@ -176,9 +176,8 @@ class AppRouter extends RootStackRouter {
|
||||
AutoRoute(page: LibraryRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: AlbumsRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
],
|
||||
transitionsBuilder: TransitionsBuilders.fadeIn,
|
||||
),
|
||||
CustomRoute(
|
||||
AutoRoute(
|
||||
page: TabShellRoute.page,
|
||||
guards: [_authGuard, _duplicateGuard],
|
||||
children: [
|
||||
@@ -187,7 +186,6 @@ class AppRouter extends RootStackRouter {
|
||||
AutoRoute(page: DriftLibraryRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: DriftAlbumsRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
],
|
||||
transitionsBuilder: TransitionsBuilders.fadeIn,
|
||||
),
|
||||
CustomRoute(
|
||||
page: GalleryViewerRoute.page,
|
||||
|
||||
78
pnpm-lock.yaml
generated
78
pnpm-lock.yaml
generated
@@ -20,8 +20,8 @@ importers:
|
||||
.github:
|
||||
devDependencies:
|
||||
prettier:
|
||||
specifier: ^3.5.3
|
||||
version: 3.7.1
|
||||
specifier: ^3.7.4
|
||||
version: 3.7.4
|
||||
|
||||
cli:
|
||||
dependencies:
|
||||
@@ -85,7 +85,7 @@ importers:
|
||||
version: 10.1.8(eslint@9.39.1(jiti@2.6.1))
|
||||
eslint-plugin-prettier:
|
||||
specifier: ^5.1.3
|
||||
version: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.7.1)
|
||||
version: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.7.4)
|
||||
eslint-plugin-unicorn:
|
||||
specifier: ^62.0.0
|
||||
version: 62.0.0(eslint@9.39.1(jiti@2.6.1))
|
||||
@@ -96,11 +96,11 @@ importers:
|
||||
specifier: ^5.2.0
|
||||
version: 5.5.0
|
||||
prettier:
|
||||
specifier: ^3.2.5
|
||||
version: 3.7.1
|
||||
specifier: ^3.7.4
|
||||
version: 3.7.4
|
||||
prettier-plugin-organize-imports:
|
||||
specifier: ^4.0.0
|
||||
version: 4.3.0(prettier@3.7.1)(typescript@5.9.3)
|
||||
version: 4.3.0(prettier@3.7.4)(typescript@5.9.3)
|
||||
typescript:
|
||||
specifier: ^5.3.3
|
||||
version: 5.9.3
|
||||
@@ -184,8 +184,8 @@ importers:
|
||||
specifier: ^3.7.0
|
||||
version: 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
prettier:
|
||||
specifier: ^3.2.4
|
||||
version: 3.7.1
|
||||
specifier: ^3.7.4
|
||||
version: 3.7.4
|
||||
typescript:
|
||||
specifier: ^5.1.6
|
||||
version: 5.9.3
|
||||
@@ -239,7 +239,7 @@ importers:
|
||||
version: 10.1.8(eslint@9.39.1(jiti@2.6.1))
|
||||
eslint-plugin-prettier:
|
||||
specifier: ^5.1.3
|
||||
version: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.7.1)
|
||||
version: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.7.4)
|
||||
eslint-plugin-unicorn:
|
||||
specifier: ^62.0.0
|
||||
version: 62.0.0(eslint@9.39.1(jiti@2.6.1))
|
||||
@@ -265,11 +265,11 @@ importers:
|
||||
specifier: ^7.0.0
|
||||
version: 7.0.0
|
||||
prettier:
|
||||
specifier: ^3.2.5
|
||||
version: 3.7.1
|
||||
specifier: ^3.7.4
|
||||
version: 3.7.4
|
||||
prettier-plugin-organize-imports:
|
||||
specifier: ^4.0.0
|
||||
version: 4.3.0(prettier@3.7.1)(typescript@5.9.3)
|
||||
version: 4.3.0(prettier@3.7.4)(typescript@5.9.3)
|
||||
sharp:
|
||||
specifier: ^0.34.5
|
||||
version: 0.34.5
|
||||
@@ -655,7 +655,7 @@ importers:
|
||||
version: 10.1.8(eslint@9.39.1(jiti@2.6.1))
|
||||
eslint-plugin-prettier:
|
||||
specifier: ^5.1.3
|
||||
version: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.7.1)
|
||||
version: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.7.4)
|
||||
eslint-plugin-unicorn:
|
||||
specifier: ^62.0.0
|
||||
version: 62.0.0(eslint@9.39.1(jiti@2.6.1))
|
||||
@@ -672,11 +672,11 @@ importers:
|
||||
specifier: ^7.0.0
|
||||
version: 7.0.0
|
||||
prettier:
|
||||
specifier: ^3.0.2
|
||||
version: 3.7.1
|
||||
specifier: ^3.7.4
|
||||
version: 3.7.4
|
||||
prettier-plugin-organize-imports:
|
||||
specifier: ^4.0.0
|
||||
version: 4.3.0(prettier@3.7.1)(typescript@5.9.3)
|
||||
version: 4.3.0(prettier@3.7.4)(typescript@5.9.3)
|
||||
sql-formatter:
|
||||
specifier: ^15.0.0
|
||||
version: 15.6.10
|
||||
@@ -717,8 +717,8 @@ importers:
|
||||
specifier: file:../open-api/typescript-sdk
|
||||
version: link:../open-api/typescript-sdk
|
||||
'@immich/ui':
|
||||
specifier: ^0.49.2
|
||||
version: 0.49.3(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)
|
||||
specifier: ^0.50.0
|
||||
version: 0.50.0(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)
|
||||
'@mapbox/mapbox-gl-rtl-text':
|
||||
specifier: 0.2.3
|
||||
version: 0.2.3(mapbox-gl@1.13.3)
|
||||
@@ -904,17 +904,17 @@ importers:
|
||||
specifier: ^16.0.0
|
||||
version: 16.5.0
|
||||
prettier:
|
||||
specifier: ^3.4.2
|
||||
version: 3.7.1
|
||||
specifier: ^3.7.4
|
||||
version: 3.7.4
|
||||
prettier-plugin-organize-imports:
|
||||
specifier: ^4.0.0
|
||||
version: 4.3.0(prettier@3.7.1)(typescript@5.9.3)
|
||||
version: 4.3.0(prettier@3.7.4)(typescript@5.9.3)
|
||||
prettier-plugin-sort-json:
|
||||
specifier: ^4.1.1
|
||||
version: 4.1.1(prettier@3.7.1)
|
||||
version: 4.1.1(prettier@3.7.4)
|
||||
prettier-plugin-svelte:
|
||||
specifier: ^3.3.3
|
||||
version: 3.4.0(prettier@3.7.1)(svelte@5.45.2)
|
||||
version: 3.4.0(prettier@3.7.4)(svelte@5.45.2)
|
||||
rollup-plugin-visualizer:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.5(rollup@4.53.3)
|
||||
@@ -2989,8 +2989,8 @@ packages:
|
||||
peerDependencies:
|
||||
svelte: ^5.0.0
|
||||
|
||||
'@immich/ui@0.49.3':
|
||||
resolution: {integrity: sha512-joqT72Y6gmGK6z25Suzr2VhYANrLo43g20T4UHmbQenz/z/Ax6sl1Ao9SjIOwEkKMm9N3Txoh7WOOzmHVl04OA==}
|
||||
'@immich/ui@0.50.0':
|
||||
resolution: {integrity: sha512-7AW9SRZTAgal8xlkUAxm7o4+pSG7HcKb+Bh9JpWLaDRRdGyPCZMmsNa9CjZglOQ7wkAD07tQ9u4+zezBLe0dlQ==}
|
||||
peerDependencies:
|
||||
svelte: ^5.0.0
|
||||
|
||||
@@ -9765,8 +9765,8 @@ packages:
|
||||
prettier: ^3.0.0
|
||||
svelte: ^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0
|
||||
|
||||
prettier@3.7.1:
|
||||
resolution: {integrity: sha512-RWKXE4qB3u5Z6yz7omJkjWwmTfLdcbv44jUVHC5NpfXwFGzvpQM798FGv/6WNK879tc+Cn0AAyherCl1KjbyZQ==}
|
||||
prettier@3.7.4:
|
||||
resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==}
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
|
||||
@@ -14517,7 +14517,7 @@ snapshots:
|
||||
'@fig/complete-commander@3.2.0(commander@11.1.0)':
|
||||
dependencies:
|
||||
commander: 11.1.0
|
||||
prettier: 3.7.1
|
||||
prettier: 3.7.4
|
||||
|
||||
'@floating-ui/core@1.7.3':
|
||||
dependencies:
|
||||
@@ -14700,7 +14700,7 @@ snapshots:
|
||||
dependencies:
|
||||
svelte: 5.45.2
|
||||
|
||||
'@immich/ui@0.49.3(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)':
|
||||
'@immich/ui@0.50.0(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)':
|
||||
dependencies:
|
||||
'@immich/svelte-markdown-preprocess': 0.1.0(svelte@5.45.2)
|
||||
'@internationalized/date': 3.10.0
|
||||
@@ -15788,7 +15788,7 @@ snapshots:
|
||||
'@react-email/render@1.4.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||
dependencies:
|
||||
html-to-text: 9.0.5
|
||||
prettier: 3.7.1
|
||||
prettier: 3.7.4
|
||||
react: 19.2.0
|
||||
react-dom: 19.2.0(react@19.2.0)
|
||||
react-promise-suspense: 0.3.4
|
||||
@@ -18907,10 +18907,10 @@ snapshots:
|
||||
lodash.memoize: 4.1.2
|
||||
semver: 7.7.3
|
||||
|
||||
eslint-plugin-prettier@5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.7.1):
|
||||
eslint-plugin-prettier@5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.7.4):
|
||||
dependencies:
|
||||
eslint: 9.39.1(jiti@2.6.1)
|
||||
prettier: 3.7.1
|
||||
prettier: 3.7.4
|
||||
prettier-linter-helpers: 1.0.0
|
||||
synckit: 0.11.11
|
||||
optionalDependencies:
|
||||
@@ -22636,21 +22636,21 @@ snapshots:
|
||||
dependencies:
|
||||
fast-diff: 1.3.0
|
||||
|
||||
prettier-plugin-organize-imports@4.3.0(prettier@3.7.1)(typescript@5.9.3):
|
||||
prettier-plugin-organize-imports@4.3.0(prettier@3.7.4)(typescript@5.9.3):
|
||||
dependencies:
|
||||
prettier: 3.7.1
|
||||
prettier: 3.7.4
|
||||
typescript: 5.9.3
|
||||
|
||||
prettier-plugin-sort-json@4.1.1(prettier@3.7.1):
|
||||
prettier-plugin-sort-json@4.1.1(prettier@3.7.4):
|
||||
dependencies:
|
||||
prettier: 3.7.1
|
||||
prettier: 3.7.4
|
||||
|
||||
prettier-plugin-svelte@3.4.0(prettier@3.7.1)(svelte@5.45.2):
|
||||
prettier-plugin-svelte@3.4.0(prettier@3.7.4)(svelte@5.45.2):
|
||||
dependencies:
|
||||
prettier: 3.7.1
|
||||
prettier: 3.7.4
|
||||
svelte: 5.45.2
|
||||
|
||||
prettier@3.7.1: {}
|
||||
prettier@3.7.4: {}
|
||||
|
||||
pretty-error@4.0.0:
|
||||
dependencies:
|
||||
|
||||
@@ -153,7 +153,7 @@
|
||||
"mock-fs": "^5.2.0",
|
||||
"node-gyp": "^12.0.0",
|
||||
"pngjs": "^7.0.0",
|
||||
"prettier": "^3.0.2",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier-plugin-organize-imports": "^4.0.0",
|
||||
"sql-formatter": "^15.0.0",
|
||||
"supertest": "^7.1.0",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { asOptions } from 'src/sql-tools/helpers';
|
||||
import { register } from 'src/sql-tools/register';
|
||||
import { ColumnStorage, ColumnType, DatabaseEnum } from 'src/sql-tools/types';
|
||||
|
||||
export type ColumnValue = null | boolean | string | number | object | Date | (() => string);
|
||||
export type ColumnValue = null | boolean | string | number | Array<unknown> | object | Date | (() => string);
|
||||
|
||||
export type ColumnBaseOptions = {
|
||||
name?: string;
|
||||
|
||||
@@ -39,6 +39,10 @@ export const fromColumnValue = (columnValue?: ColumnValue) => {
|
||||
return `'${value.toISOString()}'`;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return "'{}'";
|
||||
}
|
||||
|
||||
return `'${String(value)}'`;
|
||||
};
|
||||
|
||||
|
||||
@@ -394,6 +394,20 @@ describe(schemaDiff.name, () => {
|
||||
|
||||
expect(diff.items).toEqual([]);
|
||||
});
|
||||
|
||||
it('should support arrays, ignoring types', () => {
|
||||
const diff = schemaDiff(
|
||||
fromColumn({ name: 'column1', type: 'character varying', isArray: true, default: "'{}'" }),
|
||||
fromColumn({
|
||||
name: 'column1',
|
||||
type: 'character varying',
|
||||
isArray: true,
|
||||
default: "'{}'::character varying[]",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(diff.items).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
40
server/test/sql-tools/column-default-array.stub.ts
Normal file
40
server/test/sql-tools/column-default-array.stub.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Column, DatabaseSchema, Table } from 'src/sql-tools';
|
||||
|
||||
@Table()
|
||||
export class Table1 {
|
||||
@Column({ type: 'character varying', array: true, default: [] })
|
||||
column1!: string[];
|
||||
}
|
||||
|
||||
export const description = 'should register a table with a column with a default value (array)';
|
||||
export const schema: DatabaseSchema = {
|
||||
databaseName: 'postgres',
|
||||
schemaName: 'public',
|
||||
functions: [],
|
||||
enums: [],
|
||||
extensions: [],
|
||||
parameters: [],
|
||||
overrides: [],
|
||||
tables: [
|
||||
{
|
||||
name: 'table1',
|
||||
columns: [
|
||||
{
|
||||
name: 'column1',
|
||||
tableName: 'table1',
|
||||
type: 'character varying',
|
||||
nullable: false,
|
||||
isArray: true,
|
||||
primary: false,
|
||||
synchronize: true,
|
||||
default: "'{}'",
|
||||
},
|
||||
],
|
||||
indexes: [],
|
||||
triggers: [],
|
||||
constraints: [],
|
||||
synchronize: true,
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
};
|
||||
@@ -28,7 +28,7 @@
|
||||
"@formatjs/icu-messageformat-parser": "^2.9.8",
|
||||
"@immich/justified-layout-wasm": "^0.4.3",
|
||||
"@immich/sdk": "file:../open-api/typescript-sdk",
|
||||
"@immich/ui": "^0.49.2",
|
||||
"@immich/ui": "^0.50.0",
|
||||
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@photo-sphere-viewer/core": "^5.14.0",
|
||||
@@ -93,7 +93,7 @@
|
||||
"factory.ts": "^1.4.1",
|
||||
"globals": "^16.0.0",
|
||||
"happy-dom": "^20.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier-plugin-organize-imports": "^4.0.0",
|
||||
"prettier-plugin-sort-json": "^4.1.1",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
|
||||
24
web/src/lib/components/HeaderActionButton.svelte
Normal file
24
web/src/lib/components/HeaderActionButton.svelte
Normal file
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import type { HeaderButtonActionItem } from '$lib/types';
|
||||
import { Button } from '@immich/ui';
|
||||
|
||||
type Props = {
|
||||
action: HeaderButtonActionItem;
|
||||
};
|
||||
|
||||
const { action }: Props = $props();
|
||||
const { title, icon, color = 'secondary', onAction } = $derived(action);
|
||||
</script>
|
||||
|
||||
{#if action.$if?.() ?? true}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
{color}
|
||||
leadingIcon={icon}
|
||||
onclick={() => onAction(action)}
|
||||
title={action.data?.title}
|
||||
>
|
||||
{title}
|
||||
</Button>
|
||||
{/if}
|
||||
@@ -1,17 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { type ActionItem, Button, Text } from '@immich/ui';
|
||||
|
||||
type Props = {
|
||||
action: ActionItem;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
const { action, title: titleAttr }: Props = $props();
|
||||
const { title, icon, color = 'secondary', onAction } = $derived(action);
|
||||
</script>
|
||||
|
||||
{#if action.$if?.() ?? true}
|
||||
<Button variant="ghost" size="small" {color} leadingIcon={icon} onclick={() => onAction(action)} title={titleAttr}>
|
||||
<Text class="hidden md:block">{title}</Text>
|
||||
</Button>
|
||||
{/if}
|
||||
@@ -1,19 +1,33 @@
|
||||
<script lang="ts">
|
||||
import PageContent from '$lib/components/layouts/PageContent.svelte';
|
||||
import TitleLayout from '$lib/components/layouts/TitleLayout.svelte';
|
||||
import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
|
||||
import AdminSidebar from '$lib/sidebars/AdminSidebar.svelte';
|
||||
import { sidebarStore } from '$lib/stores/sidebar.svelte';
|
||||
import { AppShell, AppShellHeader, AppShellSidebar, Scrollable, type BreadcrumbItem } from '@immich/ui';
|
||||
import type { HeaderButtonActionItem } from '$lib/types';
|
||||
import {
|
||||
AppShell,
|
||||
AppShellHeader,
|
||||
AppShellSidebar,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
ContextMenuButton,
|
||||
HStack,
|
||||
MenuItemType,
|
||||
Scrollable,
|
||||
isMenuItemType,
|
||||
type BreadcrumbItem,
|
||||
} from '@immich/ui';
|
||||
import { mdiSlashForward } from '@mdi/js';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
breadcrumbs: BreadcrumbItem[];
|
||||
buttons?: Snippet;
|
||||
actions?: Array<HeaderButtonActionItem | MenuItemType>;
|
||||
children?: Snippet;
|
||||
};
|
||||
|
||||
let { breadcrumbs, buttons, children }: Props = $props();
|
||||
let { breadcrumbs, actions = [], children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<AppShell>
|
||||
@@ -24,11 +38,37 @@
|
||||
<AdminSidebar />
|
||||
</AppShellSidebar>
|
||||
|
||||
<TitleLayout {breadcrumbs} {buttons}>
|
||||
<div class="h-full flex flex-col">
|
||||
<div class="flex h-16 w-full justify-between items-center border-b py-2 px-4 md:px-2">
|
||||
<Breadcrumbs items={breadcrumbs} separator={mdiSlashForward} />
|
||||
|
||||
{#if actions.length > 0}
|
||||
<div class="hidden md:block">
|
||||
<HStack gap={0}>
|
||||
{#each actions as action, i (i)}
|
||||
{#if !isMenuItemType(action) && (action.$if?.() ?? true)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
color={action.color ?? 'secondary'}
|
||||
leadingIcon={action.icon}
|
||||
onclick={() => action.onAction(action)}
|
||||
title={action.data?.title}
|
||||
>
|
||||
{action.title}
|
||||
</Button>
|
||||
{/if}
|
||||
{/each}
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
<ContextMenuButton aria-label={$t('open')} items={actions} class="md:hidden" />
|
||||
{/if}
|
||||
</div>
|
||||
<Scrollable class="grow">
|
||||
<PageContent>
|
||||
{@render children?.()}
|
||||
</PageContent>
|
||||
</Scrollable>
|
||||
</TitleLayout>
|
||||
</div>
|
||||
</AppShell>
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Breadcrumbs, type BreadcrumbItem } from '@immich/ui';
|
||||
import { mdiSlashForward } from '@mdi/js';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
type Props = {
|
||||
breadcrumbs: BreadcrumbItem[];
|
||||
buttons?: Snippet;
|
||||
children?: Snippet;
|
||||
};
|
||||
|
||||
let { breadcrumbs, buttons, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="h-full flex flex-col">
|
||||
<div class="flex h-16 w-full place-items-center justify-between border-b p-2">
|
||||
<Breadcrumbs items={breadcrumbs} separator={mdiSlashForward} />
|
||||
{@render buttons?.()}
|
||||
</div>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -9,9 +9,9 @@
|
||||
import { generateId } from '$lib/utils/generate-id';
|
||||
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
|
||||
import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk';
|
||||
import { IconButton, modalManager } from '@immich/ui';
|
||||
import { Button, IconButton, modalManager } from '@immich/ui';
|
||||
import { mdiClose, mdiMagnify, mdiTune } from '@mdi/js';
|
||||
import { onDestroy, tick } from 'svelte';
|
||||
import { onDestroy, onMount, tick } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import SearchHistoryBox from './search-history-box.svelte';
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
let isSearchSuggestions = $state(false);
|
||||
let selectedId: string | undefined = $state();
|
||||
let close: (() => Promise<void>) | undefined;
|
||||
let showSearchTypeDropdown = $state(false);
|
||||
let currentSearchType = $state('smart');
|
||||
|
||||
const listboxId = generateId();
|
||||
const searchTypeId = generateId();
|
||||
@@ -70,6 +72,7 @@
|
||||
|
||||
const onFocusIn = () => {
|
||||
searchStore.isSearchEnabled = true;
|
||||
getSearchType();
|
||||
};
|
||||
|
||||
const onFocusOut = () => {
|
||||
@@ -98,6 +101,9 @@
|
||||
const searchResult = await result.onClose;
|
||||
close = undefined;
|
||||
|
||||
// Refresh search type after modal closes
|
||||
getSearchType();
|
||||
|
||||
if (!searchResult) {
|
||||
return;
|
||||
}
|
||||
@@ -139,6 +145,7 @@
|
||||
|
||||
const onEscape = () => {
|
||||
closeDropdown();
|
||||
closeSearchTypeDropdown();
|
||||
};
|
||||
|
||||
const onArrow = async (direction: 1 | -1) => {
|
||||
@@ -168,6 +175,20 @@
|
||||
searchHistoryBox?.clearSelection();
|
||||
};
|
||||
|
||||
const toggleSearchTypeDropdown = () => {
|
||||
showSearchTypeDropdown = !showSearchTypeDropdown;
|
||||
};
|
||||
|
||||
const closeSearchTypeDropdown = () => {
|
||||
showSearchTypeDropdown = false;
|
||||
};
|
||||
|
||||
const selectSearchType = (type: string) => {
|
||||
localStorage.setItem('searchQueryType', type);
|
||||
currentSearchType = type;
|
||||
showSearchTypeDropdown = false;
|
||||
};
|
||||
|
||||
const onsubmit = (event: Event) => {
|
||||
event.preventDefault();
|
||||
onSubmit();
|
||||
@@ -180,17 +201,18 @@
|
||||
case 'metadata':
|
||||
case 'description':
|
||||
case 'ocr': {
|
||||
currentSearchType = searchType;
|
||||
return searchType;
|
||||
}
|
||||
default: {
|
||||
currentSearchType = 'smart';
|
||||
return 'smart';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getSearchTypeText(): string {
|
||||
const searchType = getSearchType();
|
||||
switch (searchType) {
|
||||
switch (currentSearchType) {
|
||||
case 'smart': {
|
||||
return $t('context');
|
||||
}
|
||||
@@ -203,8 +225,22 @@
|
||||
case 'ocr': {
|
||||
return $t('ocr');
|
||||
}
|
||||
default: {
|
||||
return $t('context');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
getSearchType();
|
||||
});
|
||||
|
||||
const searchTypes = [
|
||||
{ value: 'smart', label: () => $t('context') },
|
||||
{ value: 'metadata', label: () => $t('filename') },
|
||||
{ value: 'description', label: () => $t('description') },
|
||||
{ value: 'ocr', label: () => $t('ocr') },
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<svelte:document
|
||||
@@ -293,11 +329,34 @@
|
||||
class:max-md:hidden={value}
|
||||
class:end-28={value.length > 0}
|
||||
>
|
||||
<p
|
||||
class="bg-immich-primary text-white dark:bg-immich-dark-primary/90 dark:text-black/75 rounded-full px-3 py-1 text-xs"
|
||||
>
|
||||
{getSearchTypeText()}
|
||||
</p>
|
||||
<div class="relative">
|
||||
<Button
|
||||
class="bg-immich-primary text-white dark:bg-immich-dark-primary/90 dark:text-black/75 rounded-full px-3 py-1 text-xs hover:opacity-80 transition-opacity cursor-pointer"
|
||||
onclick={toggleSearchTypeDropdown}
|
||||
aria-expanded={showSearchTypeDropdown}
|
||||
aria-haspopup="listbox"
|
||||
>
|
||||
{getSearchTypeText()}
|
||||
</Button>
|
||||
|
||||
{#if showSearchTypeDropdown}
|
||||
<div
|
||||
class="absolute top-full right-0 mt-1 bg-white dark:bg-immich-dark-gray border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg py-1 min-w-32 z-9999"
|
||||
use:focusOutside={{ onFocusOut: closeSearchTypeDropdown }}
|
||||
>
|
||||
{#each searchTypes as searchType (searchType.value)}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 text-xs hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors
|
||||
{currentSearchType === searchType.value ? 'bg-gray-100 dark:bg-gray-700' : ''}"
|
||||
onclick={() => selectSearchType(searchType.value)}
|
||||
>
|
||||
{searchType.label()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ export const getLibrariesActions = ($t: MessageFormatter, libraries: LibraryResp
|
||||
title: $t('scan_all_libraries'),
|
||||
type: $t('command'),
|
||||
icon: mdiSync,
|
||||
onAction: () => void handleScanAllLibraries(),
|
||||
onAction: () => handleScanAllLibraries(),
|
||||
shortcuts: { shift: true, key: 'r' },
|
||||
$if: () => libraries.length > 0,
|
||||
};
|
||||
@@ -37,7 +37,7 @@ export const getLibrariesActions = ($t: MessageFormatter, libraries: LibraryResp
|
||||
title: $t('create_library'),
|
||||
type: $t('command'),
|
||||
icon: mdiPlusBoxOutline,
|
||||
onAction: () => void handleCreateLibrary(),
|
||||
onAction: () => handleCreateLibrary(),
|
||||
shortcuts: { shift: true, key: 'n' },
|
||||
};
|
||||
|
||||
@@ -49,7 +49,7 @@ export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponse
|
||||
icon: mdiPencilOutline,
|
||||
type: $t('command'),
|
||||
title: $t('rename'),
|
||||
onAction: () => void modalManager.show(LibraryRenameModal, { library }),
|
||||
onAction: () => modalManager.show(LibraryRenameModal, { library }),
|
||||
shortcuts: { key: 'r' },
|
||||
};
|
||||
|
||||
@@ -58,7 +58,7 @@ export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponse
|
||||
type: $t('command'),
|
||||
title: $t('delete'),
|
||||
color: 'danger',
|
||||
onAction: () => void handleDeleteLibrary(library),
|
||||
onAction: () => handleDeleteLibrary(library),
|
||||
shortcuts: { key: 'Backspace' },
|
||||
};
|
||||
|
||||
@@ -66,21 +66,21 @@ export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponse
|
||||
icon: mdiPlusBoxOutline,
|
||||
type: $t('command'),
|
||||
title: $t('add'),
|
||||
onAction: () => void modalManager.show(LibraryFolderAddModal, { library }),
|
||||
onAction: () => modalManager.show(LibraryFolderAddModal, { library }),
|
||||
};
|
||||
|
||||
const AddExclusionPattern: ActionItem = {
|
||||
icon: mdiPlusBoxOutline,
|
||||
type: $t('command'),
|
||||
title: $t('add'),
|
||||
onAction: () => void modalManager.show(LibraryExclusionPatternAddModal, { library }),
|
||||
onAction: () => modalManager.show(LibraryExclusionPatternAddModal, { library }),
|
||||
};
|
||||
|
||||
const Scan: ActionItem = {
|
||||
icon: mdiSync,
|
||||
type: $t('command'),
|
||||
title: $t('scan_library'),
|
||||
onAction: () => void handleScanLibrary(library),
|
||||
onAction: () => handleScanLibrary(library),
|
||||
shortcuts: { shift: true, key: 'r' },
|
||||
};
|
||||
|
||||
@@ -92,14 +92,14 @@ export const getLibraryFolderActions = ($t: MessageFormatter, library: LibraryRe
|
||||
icon: mdiPencilOutline,
|
||||
type: $t('command'),
|
||||
title: $t('edit'),
|
||||
onAction: () => void modalManager.show(LibraryFolderEditModal, { folder, library }),
|
||||
onAction: () => modalManager.show(LibraryFolderEditModal, { folder, library }),
|
||||
};
|
||||
|
||||
const Delete: ActionItem = {
|
||||
icon: mdiTrashCanOutline,
|
||||
type: $t('command'),
|
||||
title: $t('delete'),
|
||||
onAction: () => void handleDeleteLibraryFolder(library, folder),
|
||||
onAction: () => handleDeleteLibraryFolder(library, folder),
|
||||
};
|
||||
|
||||
return { Edit, Delete };
|
||||
@@ -114,14 +114,14 @@ export const getLibraryExclusionPatternActions = (
|
||||
icon: mdiPencilOutline,
|
||||
type: $t('command'),
|
||||
title: $t('edit'),
|
||||
onAction: () => void modalManager.show(LibraryExclusionPatternEditModal, { exclusionPattern, library }),
|
||||
onAction: () => modalManager.show(LibraryExclusionPatternEditModal, { exclusionPattern, library }),
|
||||
};
|
||||
|
||||
const Delete: ActionItem = {
|
||||
icon: mdiTrashCanOutline,
|
||||
type: $t('command'),
|
||||
title: $t('delete'),
|
||||
onAction: () => void handleDeleteExclusionPattern(library, exclusionPattern),
|
||||
onAction: () => handleDeleteExclusionPattern(library, exclusionPattern),
|
||||
};
|
||||
|
||||
return { Edit, Delete };
|
||||
@@ -273,7 +273,7 @@ const handleDeleteLibraryFolder = async (library: LibraryResponseDto, folder: st
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -285,10 +285,7 @@ const handleDeleteLibraryFolder = async (library: LibraryResponseDto, folder: st
|
||||
toastManager.success($t('admin.library_updated'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_update_library'));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const handleAddLibraryExclusionPattern = async (library: LibraryResponseDto, exclusionPattern: string) => {
|
||||
@@ -345,9 +342,8 @@ const handleDeleteExclusionPattern = async (library: LibraryResponseDto, exclusi
|
||||
const $t = await getFormatter();
|
||||
|
||||
const confirmed = await modalManager.showDialog({ prompt: $t('admin.library_remove_exclusion_pattern_prompt') });
|
||||
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -361,8 +357,5 @@ const handleDeleteExclusionPattern = async (library: LibraryResponseDto, exclusi
|
||||
toastManager.success($t('admin.library_updated'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_update_library'));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { queueManager } from '$lib/managers/queue-manager.svelte';
|
||||
import JobCreateModal from '$lib/modals/JobCreateModal.svelte';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import type { HeaderButtonActionItem } from '$lib/types';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { emptyQueue, getQueue, QueueName, updateQueue, type QueueResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
emptyQueue,
|
||||
getQueue,
|
||||
QueueCommand,
|
||||
QueueName,
|
||||
runQueueCommandLegacy,
|
||||
updateQueue,
|
||||
type QueueResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { modalManager, toastManager, type ActionItem, type IconLike } from '@immich/ui';
|
||||
import {
|
||||
mdiClose,
|
||||
@@ -23,7 +32,6 @@ import {
|
||||
mdiPlay,
|
||||
mdiPlus,
|
||||
mdiStateMachine,
|
||||
mdiSync,
|
||||
mdiTable,
|
||||
mdiTagFaces,
|
||||
mdiTrashCanOutline,
|
||||
@@ -31,7 +39,6 @@ import {
|
||||
mdiVideo,
|
||||
} from '@mdi/js';
|
||||
import type { MessageFormatter } from 'svelte-i18n';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
type QueueItem = {
|
||||
icon: IconLike;
|
||||
@@ -39,15 +46,17 @@ type QueueItem = {
|
||||
subtitle?: string;
|
||||
};
|
||||
|
||||
export const getQueuesActions = ($t: MessageFormatter) => {
|
||||
const ViewQueues: ActionItem = {
|
||||
title: $t('admin.queues'),
|
||||
description: $t('admin.queues_page_description'),
|
||||
icon: mdiSync,
|
||||
type: $t('page'),
|
||||
isGlobal: true,
|
||||
$if: () => get(user)?.isAdmin,
|
||||
onAction: () => goto(AppRoute.ADMIN_QUEUES),
|
||||
export const getQueuesActions = ($t: MessageFormatter, queues: QueueResponseDto[] | undefined) => {
|
||||
const pausedQueues = (queues ?? []).filter(({ isPaused }) => isPaused).map(({ name }) => name);
|
||||
|
||||
const ResumePaused: HeaderButtonActionItem = {
|
||||
title: $t('resume_paused_jobs', { values: { count: pausedQueues.length } }),
|
||||
$if: () => pausedQueues.length > 0,
|
||||
icon: mdiPlay,
|
||||
onAction: () => handleResumePausedJobs(pausedQueues),
|
||||
data: {
|
||||
title: pausedQueues.join(', '),
|
||||
},
|
||||
};
|
||||
|
||||
const CreateJob: ActionItem = {
|
||||
@@ -68,7 +77,7 @@ export const getQueuesActions = ($t: MessageFormatter) => {
|
||||
onAction: () => goto(`${AppRoute.ADMIN_SETTINGS}?isOpen=job`),
|
||||
};
|
||||
|
||||
return { ViewQueues, ManageConcurrency, CreateJob };
|
||||
return { ResumePaused, ManageConcurrency, CreateJob };
|
||||
};
|
||||
|
||||
export const getQueueActions = ($t: MessageFormatter, queue: QueueResponseDto) => {
|
||||
@@ -126,6 +135,19 @@ export const handleEmptyQueue = async (queue: QueueResponseDto) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleResumePausedJobs = async (queues: QueueName[]) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
try {
|
||||
for (const name of queues) {
|
||||
await runQueueCommandLegacy({ name, queueCommandDto: { command: QueueCommand.Resume, force: false } });
|
||||
}
|
||||
await queueManager.refresh();
|
||||
} catch (error) {
|
||||
handleError(error, $t('admin.failed_job_command', { values: { command: 'resume', job: 'paused jobs' } }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFailedJobs = async (queue: QueueResponseDto) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
|
||||
@@ -24,26 +24,26 @@ export const getSharedLinkActions = ($t: MessageFormatter, sharedLink: SharedLin
|
||||
const Edit: ActionItem = {
|
||||
title: $t('edit_link'),
|
||||
icon: mdiPencilOutline,
|
||||
onAction: () => void goto(`${AppRoute.SHARED_LINKS}/${sharedLink.id}`),
|
||||
onAction: () => goto(`${AppRoute.SHARED_LINKS}/${sharedLink.id}`),
|
||||
};
|
||||
|
||||
const Delete: ActionItem = {
|
||||
title: $t('delete_link'),
|
||||
icon: mdiTrashCanOutline,
|
||||
color: 'danger',
|
||||
onAction: () => void handleDeleteSharedLink(sharedLink),
|
||||
onAction: () => handleDeleteSharedLink(sharedLink),
|
||||
};
|
||||
|
||||
const Copy: ActionItem = {
|
||||
title: $t('copy_link'),
|
||||
icon: mdiContentCopy,
|
||||
onAction: () => void copyToClipboard(asUrl(sharedLink)),
|
||||
onAction: () => copyToClipboard(asUrl(sharedLink)),
|
||||
};
|
||||
|
||||
const ViewQrCode: ActionItem = {
|
||||
title: $t('view_qr_code'),
|
||||
icon: mdiQrcode,
|
||||
onAction: () => void handleShowSharedLinkQrCode(sharedLink),
|
||||
onAction: () => handleShowSharedLinkQrCode(sharedLink),
|
||||
};
|
||||
|
||||
return { Edit, Delete, Copy, ViewQrCode };
|
||||
@@ -88,7 +88,7 @@ export const handleUpdateSharedLink = async (sharedLink: SharedLinkResponseDto,
|
||||
}
|
||||
};
|
||||
|
||||
export const handleDeleteSharedLink = async (sharedLink: SharedLinkResponseDto): Promise<boolean> => {
|
||||
const handleDeleteSharedLink = async (sharedLink: SharedLinkResponseDto) => {
|
||||
const $t = await getFormatter();
|
||||
const success = await modalManager.showDialog({
|
||||
title: $t('delete_shared_link'),
|
||||
@@ -96,17 +96,15 @@ export const handleDeleteSharedLink = async (sharedLink: SharedLinkResponseDto):
|
||||
confirmText: $t('delete'),
|
||||
});
|
||||
if (!success) {
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await removeSharedLink({ id: sharedLink.id });
|
||||
eventManager.emit('SharedLinkDelete', sharedLink);
|
||||
toastManager.success($t('deleted_shared_link'));
|
||||
return true;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_delete_shared_link'));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ export const getSystemConfigActions = (
|
||||
description: $t('admin.copy_config_to_clipboard_description'),
|
||||
type: $t('command'),
|
||||
icon: mdiContentCopy,
|
||||
onAction: () => void handleCopyToClipboard(config),
|
||||
onAction: () => handleCopyToClipboard(config),
|
||||
shortcuts: { shift: true, key: 'c' },
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
|
||||
import PasswordResetSuccessModal from '$lib/modals/PasswordResetSuccessModal.svelte';
|
||||
import UserCreateModal from '$lib/modals/UserCreateModal.svelte';
|
||||
import UserDeleteConfirmModal from '$lib/modals/UserDeleteConfirmModal.svelte';
|
||||
import UserEditModal from '$lib/modals/UserEditModal.svelte';
|
||||
import UserRestoreConfirmModal from '$lib/modals/UserRestoreConfirmModal.svelte';
|
||||
import { user as authUser } from '$lib/stores/user.store';
|
||||
import type { HeaderButtonActionItem } from '$lib/types';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import {
|
||||
@@ -28,6 +30,7 @@ import {
|
||||
mdiPlusBoxOutline,
|
||||
mdiTrashCanOutline,
|
||||
} from '@mdi/js';
|
||||
import { DateTime } from 'luxon';
|
||||
import type { MessageFormatter } from 'svelte-i18n';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
@@ -36,7 +39,7 @@ export const getUserAdminsActions = ($t: MessageFormatter) => {
|
||||
title: $t('create_user'),
|
||||
type: $t('command'),
|
||||
icon: mdiPlusBoxOutline,
|
||||
onAction: () => void modalManager.show(UserCreateModal, {}),
|
||||
onAction: () => modalManager.show(UserCreateModal, {}),
|
||||
shortcuts: { shift: true, key: 'n' },
|
||||
};
|
||||
|
||||
@@ -60,11 +63,17 @@ export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminRespons
|
||||
shortcuts: { key: 'Backspace' },
|
||||
};
|
||||
|
||||
const Restore: ActionItem = {
|
||||
const getDeleteDate = (deletedAt: string): Date =>
|
||||
DateTime.fromISO(deletedAt).plus({ days: serverConfigManager.value.userDeleteDelay }).toJSDate();
|
||||
|
||||
const Restore: HeaderButtonActionItem = {
|
||||
icon: mdiDeleteRestore,
|
||||
title: $t('restore'),
|
||||
type: $t('command'),
|
||||
color: 'primary',
|
||||
data: {
|
||||
title: $t('admin.user_restore_scheduled_removal', { values: { date: getDeleteDate(user.deletedAt!) } }),
|
||||
},
|
||||
$if: () => !!user.deletedAt && user.status === UserStatus.Deleted,
|
||||
onAction: () => modalManager.show(UserRestoreConfirmModal, { user }),
|
||||
};
|
||||
@@ -74,14 +83,14 @@ export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminRespons
|
||||
title: $t('reset_password'),
|
||||
type: $t('command'),
|
||||
$if: () => get(authUser).id !== user.id,
|
||||
onAction: () => void handleResetPasswordUserAdmin(user),
|
||||
onAction: () => handleResetPasswordUserAdmin(user),
|
||||
};
|
||||
|
||||
const ResetPinCode: ActionItem = {
|
||||
icon: mdiLockSmart,
|
||||
type: $t('command'),
|
||||
title: $t('reset_pin_code'),
|
||||
onAction: () => void handleResetPinCodeUserAdmin(user),
|
||||
onAction: () => handleResetPinCodeUserAdmin(user),
|
||||
};
|
||||
|
||||
return { Update, Delete, Restore, ResetPassword, ResetPinCode };
|
||||
@@ -162,12 +171,12 @@ const generatePassword = (length: number = 16) => {
|
||||
return generatedPassword;
|
||||
};
|
||||
|
||||
export const handleResetPasswordUserAdmin = async (user: UserAdminResponseDto) => {
|
||||
const handleResetPasswordUserAdmin = async (user: UserAdminResponseDto) => {
|
||||
const $t = await getFormatter();
|
||||
const prompt = $t('admin.confirm_user_password_reset', { values: { user: user.name } });
|
||||
const success = await modalManager.showDialog({ prompt });
|
||||
if (!success) {
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -176,28 +185,24 @@ export const handleResetPasswordUserAdmin = async (user: UserAdminResponseDto) =
|
||||
eventManager.emit('UserAdminUpdate', response);
|
||||
toastManager.success();
|
||||
await modalManager.show(PasswordResetSuccessModal, { newPassword: dto.password });
|
||||
return true;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_reset_password'));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const handleResetPinCodeUserAdmin = async (user: UserAdminResponseDto) => {
|
||||
const handleResetPinCodeUserAdmin = async (user: UserAdminResponseDto) => {
|
||||
const $t = await getFormatter();
|
||||
const prompt = $t('admin.confirm_user_pin_code_reset', { values: { user: user.name } });
|
||||
const success = await modalManager.showDialog({ prompt });
|
||||
if (!success) {
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await updateUserAdmin({ id: user.id, userAdminUpdateDto: { pinCode: null } });
|
||||
eventManager.emit('UserAdminUpdate', response);
|
||||
toastManager.success($t('pin_code_reset_successfully'));
|
||||
return true;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_reset_pin_code'));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { QueueResponseDto, ServerVersionResponseDto } from '@immich/sdk';
|
||||
import type { ActionItem } from '@immich/ui';
|
||||
|
||||
export interface ReleaseEvent {
|
||||
isAvailable: boolean;
|
||||
@@ -9,3 +10,5 @@ export interface ReleaseEvent {
|
||||
}
|
||||
|
||||
export type QueueSnapshot = { timestamp: number; snapshot?: QueueResponseDto[] };
|
||||
|
||||
export type HeaderButtonActionItem = ActionItem & { data?: { title?: string } };
|
||||
|
||||
540
web/src/lib/utils/cancellable-task.spec.ts
Normal file
540
web/src/lib/utils/cancellable-task.spec.ts
Normal file
@@ -0,0 +1,540 @@
|
||||
import { CancellableTask } from '$lib/utils/cancellable-task';
|
||||
|
||||
describe('CancellableTask', () => {
|
||||
describe('execute', () => {
|
||||
it('should execute task successfully and return LOADED', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async (_: AbortSignal) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
|
||||
const result = await task.execute(taskFn, true);
|
||||
|
||||
expect(result).toBe('LOADED');
|
||||
expect(task.executed).toBe(true);
|
||||
expect(task.loading).toBe(false);
|
||||
expect(taskFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call loadedCallback when task completes successfully', async () => {
|
||||
const loadedCallback = vi.fn();
|
||||
const task = new CancellableTask(loadedCallback);
|
||||
const taskFn = vi.fn(async () => {});
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
|
||||
expect(loadedCallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return DONE if task is already executed', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {});
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
const result = await task.execute(taskFn, true);
|
||||
|
||||
expect(result).toBe('DONE');
|
||||
expect(taskFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should wait if task is already running', async () => {
|
||||
const task = new CancellableTask();
|
||||
let resolveTask: () => void;
|
||||
const taskPromise = new Promise<void>((resolve) => {
|
||||
resolveTask = resolve;
|
||||
});
|
||||
const taskFn = vi.fn(async () => {
|
||||
await taskPromise;
|
||||
});
|
||||
|
||||
const promise1 = task.execute(taskFn, true);
|
||||
const promise2 = task.execute(taskFn, true);
|
||||
|
||||
expect(task.loading).toBe(true);
|
||||
resolveTask!();
|
||||
|
||||
const [result1, result2] = await Promise.all([promise1, promise2]);
|
||||
|
||||
expect(result1).toBe('LOADED');
|
||||
expect(result2).toBe('WAITED');
|
||||
expect(taskFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should pass AbortSignal to task function', async () => {
|
||||
const task = new CancellableTask();
|
||||
let capturedSignal: AbortSignal | null = null;
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
await Promise.resolve();
|
||||
capturedSignal = signal;
|
||||
};
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
|
||||
expect(capturedSignal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it('should set cancellable flag correctly', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {});
|
||||
|
||||
expect(task.cancellable).toBe(true);
|
||||
const promise = task.execute(taskFn, false);
|
||||
expect(task.cancellable).toBe(false);
|
||||
await promise;
|
||||
});
|
||||
|
||||
it('should not allow transition from prevent cancel to allow cancel when task is running', async () => {
|
||||
const task = new CancellableTask();
|
||||
let resolveTask: () => void;
|
||||
const taskPromise = new Promise<void>((resolve) => {
|
||||
resolveTask = resolve;
|
||||
});
|
||||
const taskFn = vi.fn(async () => {
|
||||
await taskPromise;
|
||||
});
|
||||
|
||||
const promise1 = task.execute(taskFn, false);
|
||||
expect(task.cancellable).toBe(false);
|
||||
|
||||
const promise2 = task.execute(taskFn, true);
|
||||
expect(task.cancellable).toBe(false);
|
||||
|
||||
resolveTask!();
|
||||
await Promise.all([promise1, promise2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancel', () => {
|
||||
it('should cancel a running task', async () => {
|
||||
const task = new CancellableTask();
|
||||
let taskStarted = false;
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
taskStarted = true;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (signal.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
};
|
||||
|
||||
const promise = task.execute(taskFn, true);
|
||||
|
||||
// Wait a bit to ensure task has started
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(taskStarted).toBe(true);
|
||||
|
||||
task.cancel();
|
||||
|
||||
const result = await promise;
|
||||
expect(result).toBe('CANCELED');
|
||||
expect(task.executed).toBe(false);
|
||||
});
|
||||
|
||||
it('should call canceledCallback when task is canceled', async () => {
|
||||
const canceledCallback = vi.fn();
|
||||
const task = new CancellableTask(undefined, canceledCallback);
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (signal.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
};
|
||||
|
||||
const promise = task.execute(taskFn, true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
task.cancel();
|
||||
await promise;
|
||||
|
||||
expect(canceledCallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not cancel if task is not cancellable', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
});
|
||||
|
||||
const promise = task.execute(taskFn, false);
|
||||
task.cancel();
|
||||
const result = await promise;
|
||||
|
||||
expect(result).toBe('LOADED');
|
||||
expect(task.executed).toBe(true);
|
||||
});
|
||||
|
||||
it('should not cancel if task is already executed', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {});
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
expect(task.executed).toBe(true);
|
||||
|
||||
task.cancel();
|
||||
expect(task.executed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('should reset task to initial state', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {});
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
expect(task.executed).toBe(true);
|
||||
|
||||
await task.reset();
|
||||
|
||||
expect(task.executed).toBe(false);
|
||||
expect(task.cancelToken).toBe(null);
|
||||
expect(task.loading).toBe(false);
|
||||
});
|
||||
|
||||
it('should cancel running task before resetting', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (signal.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
};
|
||||
|
||||
const promise = task.execute(taskFn, true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
const resetPromise = task.reset();
|
||||
|
||||
await promise;
|
||||
await resetPromise;
|
||||
|
||||
expect(task.executed).toBe(false);
|
||||
expect(task.loading).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow re-execution after reset', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {});
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
await task.reset();
|
||||
const result = await task.execute(taskFn, true);
|
||||
|
||||
expect(result).toBe('LOADED');
|
||||
expect(task.executed).toBe(true);
|
||||
expect(taskFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('waitUntilCompletion', () => {
|
||||
it('should return DONE if task is already executed', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {});
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
const result = await task.waitUntilCompletion();
|
||||
|
||||
expect(result).toBe('DONE');
|
||||
});
|
||||
|
||||
it('should return WAITED if task completes while waiting', async () => {
|
||||
const task = new CancellableTask();
|
||||
let resolveTask: () => void;
|
||||
const taskPromise = new Promise<void>((resolve) => {
|
||||
resolveTask = resolve;
|
||||
});
|
||||
const taskFn = async () => {
|
||||
await taskPromise;
|
||||
};
|
||||
|
||||
const executePromise = task.execute(taskFn, true);
|
||||
const waitPromise = task.waitUntilCompletion();
|
||||
|
||||
resolveTask!();
|
||||
|
||||
const [, waitResult] = await Promise.all([executePromise, waitPromise]);
|
||||
|
||||
expect(waitResult).toBe('WAITED');
|
||||
});
|
||||
|
||||
it('should return CANCELED if task is canceled', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (signal.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
};
|
||||
|
||||
const executePromise = task.execute(taskFn, true);
|
||||
const waitPromise = task.waitUntilCompletion();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
task.cancel();
|
||||
|
||||
const [, waitResult] = await Promise.all([executePromise, waitPromise]);
|
||||
|
||||
expect(waitResult).toBe('CANCELED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('waitUntilExecution', () => {
|
||||
it('should return DONE if task is already executed', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {});
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
const result = await task.waitUntilExecution();
|
||||
|
||||
expect(result).toBe('DONE');
|
||||
});
|
||||
|
||||
it('should return WAITED if task completes successfully', async () => {
|
||||
const task = new CancellableTask();
|
||||
let resolveTask: () => void;
|
||||
const taskPromise = new Promise<void>((resolve) => {
|
||||
resolveTask = resolve;
|
||||
});
|
||||
const taskFn = async () => {
|
||||
await taskPromise;
|
||||
};
|
||||
|
||||
const executePromise = task.execute(taskFn, true);
|
||||
const waitPromise = task.waitUntilExecution();
|
||||
|
||||
resolveTask!();
|
||||
|
||||
const [, waitResult] = await Promise.all([executePromise, waitPromise]);
|
||||
|
||||
expect(waitResult).toBe('WAITED');
|
||||
});
|
||||
|
||||
it('should retry if task is canceled and wait for next execution', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const task = new CancellableTask();
|
||||
let attempt = 0;
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
attempt++;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (signal.aborted && attempt === 1) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
};
|
||||
|
||||
// Start first execution
|
||||
const executePromise1 = task.execute(taskFn, true);
|
||||
const waitPromise = task.waitUntilExecution();
|
||||
|
||||
// Cancel the first execution
|
||||
vi.advanceTimersByTime(10);
|
||||
task.cancel();
|
||||
vi.advanceTimersByTime(100);
|
||||
await executePromise1;
|
||||
|
||||
// Start second execution
|
||||
const executePromise2 = task.execute(taskFn, true);
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
const [executeResult, waitResult] = await Promise.all([executePromise2, waitPromise]);
|
||||
|
||||
expect(executeResult).toBe('LOADED');
|
||||
expect(waitResult).toBe('WAITED');
|
||||
expect(attempt).toBe(2);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should return ERRORED when task throws non-abort error', async () => {
|
||||
const task = new CancellableTask();
|
||||
const error = new Error('Task failed');
|
||||
const taskFn = async () => {
|
||||
await Promise.resolve();
|
||||
throw error;
|
||||
};
|
||||
|
||||
const result = await task.execute(taskFn, true);
|
||||
|
||||
expect(result).toBe('ERRORED');
|
||||
expect(task.executed).toBe(false);
|
||||
});
|
||||
|
||||
it('should call errorCallback when task throws non-abort error', async () => {
|
||||
const errorCallback = vi.fn();
|
||||
const task = new CancellableTask(undefined, undefined, errorCallback);
|
||||
const error = new Error('Task failed');
|
||||
const taskFn = async () => {
|
||||
await Promise.resolve();
|
||||
throw error;
|
||||
};
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
|
||||
expect(errorCallback).toHaveBeenCalledTimes(1);
|
||||
expect(errorCallback).toHaveBeenCalledWith(error);
|
||||
});
|
||||
|
||||
it('should return CANCELED when task throws AbortError', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = async () => {
|
||||
await Promise.resolve();
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
};
|
||||
|
||||
const result = await task.execute(taskFn, true);
|
||||
|
||||
expect(result).toBe('CANCELED');
|
||||
expect(task.executed).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow re-execution after error', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn1 = async () => {
|
||||
await Promise.resolve();
|
||||
throw new Error('Failed');
|
||||
};
|
||||
const taskFn2 = vi.fn(async () => {});
|
||||
|
||||
const result1 = await task.execute(taskFn1, true);
|
||||
expect(result1).toBe('ERRORED');
|
||||
|
||||
const result2 = await task.execute(taskFn2, true);
|
||||
expect(result2).toBe('LOADED');
|
||||
expect(task.executed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading property', () => {
|
||||
it('should return true when task is running', async () => {
|
||||
const task = new CancellableTask();
|
||||
let resolveTask: () => void;
|
||||
const taskPromise = new Promise<void>((resolve) => {
|
||||
resolveTask = resolve;
|
||||
});
|
||||
const taskFn = async () => {
|
||||
await taskPromise;
|
||||
};
|
||||
|
||||
expect(task.loading).toBe(false);
|
||||
|
||||
const promise = task.execute(taskFn, true);
|
||||
expect(task.loading).toBe(true);
|
||||
|
||||
resolveTask!();
|
||||
await promise;
|
||||
|
||||
expect(task.loading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('complete promise', () => {
|
||||
it('should resolve when task completes successfully', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {});
|
||||
|
||||
const completePromise = task.complete;
|
||||
await task.execute(taskFn, true);
|
||||
await expect(completePromise).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject when task is canceled', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (signal.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
};
|
||||
|
||||
const completePromise = task.complete;
|
||||
const promise = task.execute(taskFn, true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
task.cancel();
|
||||
await promise;
|
||||
|
||||
await expect(completePromise).rejects.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject when task errors', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = async () => {
|
||||
await Promise.resolve();
|
||||
throw new Error('Failed');
|
||||
};
|
||||
|
||||
const completePromise = task.complete;
|
||||
await task.execute(taskFn, true);
|
||||
|
||||
await expect(completePromise).rejects.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('abort signal handling', () => {
|
||||
it('should automatically call abort() on signal when task is canceled', async () => {
|
||||
const task = new CancellableTask();
|
||||
let capturedSignal: AbortSignal | null = null;
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
capturedSignal = signal;
|
||||
// Simulate a long-running task
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (signal.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
};
|
||||
|
||||
const promise = task.execute(taskFn, true);
|
||||
|
||||
// Wait a bit to ensure task has started
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(capturedSignal).not.toBeNull();
|
||||
expect(capturedSignal!.aborted).toBe(false);
|
||||
|
||||
// Cancel the task
|
||||
task.cancel();
|
||||
|
||||
// Verify the signal was aborted
|
||||
expect(capturedSignal!.aborted).toBe(true);
|
||||
|
||||
const result = await promise;
|
||||
expect(result).toBe('CANCELED');
|
||||
});
|
||||
|
||||
it('should detect if signal was aborted after task completes', async () => {
|
||||
const task = new CancellableTask();
|
||||
let controller: AbortController | null = null;
|
||||
const taskFn = async (_: AbortSignal) => {
|
||||
// Capture the controller to abort it externally
|
||||
controller = task.cancelToken;
|
||||
// Simulate some work
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
// Now abort before the function returns
|
||||
controller?.abort();
|
||||
};
|
||||
|
||||
const result = await task.execute(taskFn, true);
|
||||
|
||||
expect(result).toBe('CANCELED');
|
||||
expect(task.executed).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle abort signal in async operations', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
// Simulate listening to abort signal during async operation
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
signal.addEventListener('abort', () => {
|
||||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
});
|
||||
setTimeout(() => resolve(), 100);
|
||||
});
|
||||
};
|
||||
|
||||
const promise = task.execute(taskFn, true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
task.cancel();
|
||||
|
||||
const result = await promise;
|
||||
expect(result).toBe('CANCELED');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,15 +15,7 @@ export class CancellableTask {
|
||||
private canceledCallback?: () => void,
|
||||
private errorCallback?: (error: unknown) => void,
|
||||
) {
|
||||
this.complete = new Promise<void>((resolve, reject) => {
|
||||
this.loadedSignal = resolve;
|
||||
this.canceledSignal = reject;
|
||||
}).catch(
|
||||
() =>
|
||||
// if no-one waits on complete its rejected a uncaught rejection message is logged.
|
||||
// prevent this message with an empty reject handler, since waiting on a bucket is optional.
|
||||
void 0,
|
||||
);
|
||||
this.init();
|
||||
}
|
||||
|
||||
get loading() {
|
||||
@@ -34,11 +26,30 @@ export class CancellableTask {
|
||||
if (this.executed) {
|
||||
return 'DONE';
|
||||
}
|
||||
// if there is a cancel token, task is currently executing, so wait on the promise. If it
|
||||
// isn't, then the task is in new state, it hasn't been loaded, nor has it been executed.
|
||||
// in either case, we wait on the promise.
|
||||
await this.complete;
|
||||
return 'WAITED';
|
||||
// The `complete` promise resolves when executed, rejects when canceled/errored.
|
||||
try {
|
||||
const complete = this.complete;
|
||||
await complete;
|
||||
return 'WAITED';
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return 'CANCELED';
|
||||
}
|
||||
|
||||
async waitUntilExecution() {
|
||||
// Keep retrying until the task completes successfully (not canceled)
|
||||
for (;;) {
|
||||
try {
|
||||
if (this.executed) {
|
||||
return 'DONE';
|
||||
}
|
||||
await this.complete;
|
||||
return 'WAITED';
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async execute<F extends (abortSignal: AbortSignal) => Promise<void>>(f: F, cancellable: boolean) {
|
||||
@@ -80,21 +91,14 @@ export class CancellableTask {
|
||||
}
|
||||
|
||||
private init() {
|
||||
this.cancelToken = null;
|
||||
this.executed = false;
|
||||
// create a promise, and store its resolve/reject callbacks. The loadedSignal callback
|
||||
// will be incoked when a bucket is loaded, fulfilling the promise. The canceledSignal
|
||||
// callback will be called if the bucket is canceled before it was loaded, rejecting the
|
||||
// promise.
|
||||
this.complete = new Promise<void>((resolve, reject) => {
|
||||
this.cancelToken = null;
|
||||
this.executed = false;
|
||||
this.loadedSignal = resolve;
|
||||
this.canceledSignal = reject;
|
||||
}).catch(
|
||||
() =>
|
||||
// if no-one waits on complete its rejected a uncaught rejection message is logged.
|
||||
// prevent this message with an empty reject handler, since waiting on a bucket is optional.
|
||||
void 0,
|
||||
);
|
||||
});
|
||||
// Suppress unhandled rejection warning
|
||||
this.complete.catch(() => {});
|
||||
}
|
||||
|
||||
// will reset this job back to the initial state (isLoaded=false, no errors, etc)
|
||||
|
||||
@@ -14,15 +14,15 @@
|
||||
import { themeManager } from '$lib/managers/theme-manager.svelte';
|
||||
import ServerRestartingModal from '$lib/modals/ServerRestartingModal.svelte';
|
||||
import VersionAnnouncementModal from '$lib/modals/VersionAnnouncementModal.svelte';
|
||||
import { getQueuesActions } from '$lib/services/queue.service';
|
||||
import { sidebarStore } from '$lib/stores/sidebar.svelte';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { closeWebsocketConnection, openWebsocketConnection, websocketStore } from '$lib/stores/websocket';
|
||||
import type { ReleaseEvent } from '$lib/types';
|
||||
import { copyToClipboard, getReleaseType, semverToName } from '$lib/utils';
|
||||
import { maintenanceShouldRedirect } from '$lib/utils/maintenance';
|
||||
import { isAssetViewerRoute } from '$lib/utils/navigation';
|
||||
import { CommandPaletteContext, modalManager, setTranslations, type ActionItem } from '@immich/ui';
|
||||
import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiThemeLightDark } from '@mdi/js';
|
||||
import { CommandPaletteContext, modalManager, setTranslations, toastManager, type ActionItem } from '@immich/ui';
|
||||
import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiSync, mdiThemeLightDark } from '@mdi/js';
|
||||
import { onMount, type Snippet } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import '../app.css';
|
||||
@@ -53,6 +53,8 @@
|
||||
return new URL(page.url.pathname + page.url.search, 'https://my.immich.app');
|
||||
};
|
||||
|
||||
toastManager.setOptions({ class: 'top-16' });
|
||||
|
||||
onMount(() => {
|
||||
const element = document.querySelector('#stencil');
|
||||
element?.remove();
|
||||
@@ -62,6 +64,10 @@
|
||||
eventManager.emit('AppInit');
|
||||
|
||||
beforeNavigate(({ from, to }) => {
|
||||
if (sidebarStore.isOpen) {
|
||||
sidebarStore.reset();
|
||||
}
|
||||
|
||||
if (isAssetViewerRoute(from) && isAssetViewerRoute(to)) {
|
||||
return;
|
||||
}
|
||||
@@ -149,6 +155,13 @@
|
||||
icon: mdiCog,
|
||||
onAction: () => goto(AppRoute.ADMIN_SETTINGS),
|
||||
},
|
||||
{
|
||||
title: $t('admin.queues'),
|
||||
description: $t('admin.queues_page_description'),
|
||||
icon: mdiSync,
|
||||
type: $t('page'),
|
||||
onAction: () => goto(AppRoute.ADMIN_QUEUES),
|
||||
},
|
||||
{
|
||||
title: $t('external_libraries'),
|
||||
description: $t('admin.external_libraries_page_description'),
|
||||
@@ -163,7 +176,7 @@
|
||||
},
|
||||
].map((route) => ({ ...route, type: $t('page'), isGlobal: true, $if: () => $user?.isAdmin }));
|
||||
|
||||
const commands = $derived([...userCommands, ...adminCommands, ...Object.values(getQueuesActions($t))]);
|
||||
const commands = $derived([...userCommands, ...adminCommands]);
|
||||
</script>
|
||||
|
||||
<OnEvents {onReleaseEvent} />
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import HeaderButton from '$lib/components/HeaderButton.svelte';
|
||||
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
||||
@@ -60,17 +59,11 @@
|
||||
|
||||
<CommandPaletteContext commands={[Create, ScanAll]} />
|
||||
|
||||
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]}>
|
||||
{#snippet buttons()}
|
||||
<div class="flex justify-end gap-2">
|
||||
<HeaderButton action={ScanAll} />
|
||||
<HeaderButton action={Create} />
|
||||
</div>
|
||||
{/snippet}
|
||||
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]} actions={[ScanAll, Create]}>
|
||||
<section class="my-4">
|
||||
<div class="flex flex-col items-center gap-2" in:fade={{ duration: 500 }}>
|
||||
{#if libraries.length > 0}
|
||||
<table class="w-3/4 text-start">
|
||||
<table class="text-start">
|
||||
<thead
|
||||
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray"
|
||||
>
|
||||
|
||||
@@ -23,7 +23,7 @@ export const load = (async ({ url }) => {
|
||||
statistics: Object.fromEntries(statistics),
|
||||
owners: Object.fromEntries(owners),
|
||||
meta: {
|
||||
title: $t('admin.external_library_management'),
|
||||
title: $t('external_libraries'),
|
||||
},
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import emptyFoldersUrl from '$lib/assets/empty-folders.svg';
|
||||
import HeaderButton from '$lib/components/HeaderButton.svelte';
|
||||
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
|
||||
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import ServerStatisticsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte';
|
||||
@@ -53,18 +53,9 @@
|
||||
<CommandPaletteContext commands={[Rename, Delete, AddFolder, AddExclusionPattern, Scan]} />
|
||||
|
||||
<AdminPageLayout
|
||||
breadcrumbs={[
|
||||
{ title: $t('admin.external_library_management'), href: AppRoute.ADMIN_LIBRARY_MANAGEMENT },
|
||||
{ title: library.name },
|
||||
]}
|
||||
breadcrumbs={[{ title: $t('external_libraries'), href: AppRoute.ADMIN_LIBRARY_MANAGEMENT }, { title: library.name }]}
|
||||
actions={[Scan, Rename, Delete]}
|
||||
>
|
||||
{#snippet buttons()}
|
||||
<div class="flex justify-end gap-2">
|
||||
<HeaderButton action={Scan} />
|
||||
<HeaderButton action={Rename} />
|
||||
<HeaderButton action={Delete} />
|
||||
</div>
|
||||
{/snippet}
|
||||
<Container size="large" center>
|
||||
<div class="grid gap-4 grid-cols-1 lg:grid-cols-2 w-full">
|
||||
<Heading tag="h1" size="large" class="col-span-full my-4">{library.name}</Heading>
|
||||
@@ -80,7 +71,7 @@
|
||||
<Icon icon={mdiFolderOutline} size="1.5rem" />
|
||||
<CardTitle>{$t('folders')}</CardTitle>
|
||||
</div>
|
||||
<HeaderButton action={AddFolder} />
|
||||
<HeaderActionButton action={AddFolder} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
@@ -120,7 +111,7 @@
|
||||
<Icon icon={mdiFilterMinusOutline} size="1.5rem" />
|
||||
<CardTitle>{$t('exclusion_pattern')}</CardTitle>
|
||||
</div>
|
||||
<HeaderButton action={AddExclusionPattern} />
|
||||
<HeaderActionButton action={AddExclusionPattern} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
<script lang="ts">
|
||||
import HeaderButton from '$lib/components/HeaderButton.svelte';
|
||||
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import JobsPanel from '$lib/components/QueuePanel.svelte';
|
||||
import { queueManager } from '$lib/managers/queue-manager.svelte';
|
||||
import { getQueuesActions } from '$lib/services/queue.service';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { QueueCommand, runQueueCommandLegacy, type QueueResponseDto } from '@immich/sdk';
|
||||
import { Button, CommandPaletteContext, HStack, Text, type ActionItem } from '@immich/ui';
|
||||
import { mdiPlay } from '@mdi/js';
|
||||
import { type QueueResponseDto } from '@immich/sdk';
|
||||
import { CommandPaletteContext, type ActionItem } from '@immich/ui';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
@@ -22,20 +19,8 @@
|
||||
onMount(() => queueManager.listen());
|
||||
|
||||
let queues = $derived<QueueResponseDto[]>(queueManager.queues);
|
||||
const pausedQueues = $derived(queues.filter(({ isPaused }) => isPaused).map(({ name }) => name));
|
||||
|
||||
const handleResumePausedJobs = async () => {
|
||||
try {
|
||||
for (const name of pausedQueues) {
|
||||
await runQueueCommandLegacy({ name, queueCommandDto: { command: QueueCommand.Resume, force: false } });
|
||||
}
|
||||
await queueManager.refresh();
|
||||
} catch (error) {
|
||||
handleError(error, $t('admin.failed_job_command', { values: { command: 'resume', job: 'paused jobs' } }));
|
||||
}
|
||||
};
|
||||
|
||||
const { CreateJob, ManageConcurrency } = $derived(getQueuesActions($t));
|
||||
const { ResumePaused, CreateJob, ManageConcurrency } = $derived(getQueuesActions($t, queueManager.queues));
|
||||
const commands: ActionItem[] = $derived([CreateJob, ManageConcurrency]);
|
||||
|
||||
const onQueueUpdate = (update: QueueResponseDto) => {
|
||||
@@ -52,27 +37,7 @@
|
||||
|
||||
<OnEvents {onQueueUpdate} />
|
||||
|
||||
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]}>
|
||||
{#snippet buttons()}
|
||||
<HStack gap={0}>
|
||||
{#if pausedQueues.length > 0}
|
||||
<Button
|
||||
leadingIcon={mdiPlay}
|
||||
onclick={handleResumePausedJobs}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
title={pausedQueues.join(', ')}
|
||||
>
|
||||
<Text class="hidden md:block">
|
||||
{$t('resume_paused_jobs', { values: { count: pausedQueues.length } })}
|
||||
</Text>
|
||||
</Button>
|
||||
{/if}
|
||||
<HeaderButton action={CreateJob} />
|
||||
<HeaderButton action={ManageConcurrency} />
|
||||
</HStack>
|
||||
{/snippet}
|
||||
|
||||
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]} actions={[ResumePaused, CreateJob, ManageConcurrency]}>
|
||||
<section id="setting-content" class="flex place-content-center sm:mx-4">
|
||||
<section class="w-full pb-28 sm:w-5/6 md:w-212.5">
|
||||
{#if queues}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import HeaderButton from '$lib/components/HeaderButton.svelte';
|
||||
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import QueueGraph from '$lib/components/QueueGraph.svelte';
|
||||
@@ -7,7 +6,18 @@
|
||||
import { queueManager } from '$lib/managers/queue-manager.svelte';
|
||||
import { asQueueItem, getQueueActions } from '$lib/services/queue.service';
|
||||
import { type QueueResponseDto } from '@immich/sdk';
|
||||
import { Badge, Card, CardBody, CardHeader, CardTitle, Container, Heading, HStack, Icon, Text } from '@immich/ui';
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Container,
|
||||
Heading,
|
||||
Icon,
|
||||
MenuItemType,
|
||||
Text,
|
||||
} from '@immich/ui';
|
||||
import { mdiClockTimeTwoOutline } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
@@ -35,15 +45,10 @@
|
||||
|
||||
<OnEvents {onQueueUpdate} />
|
||||
|
||||
<AdminPageLayout breadcrumbs={[{ title: $t('admin.queues'), href: AppRoute.ADMIN_QUEUES }, { title: item.title }]}>
|
||||
{#snippet buttons()}
|
||||
<HStack gap={0}>
|
||||
<HeaderButton action={Pause} />
|
||||
<HeaderButton action={Resume} />
|
||||
<HeaderButton action={Empty} />
|
||||
<HeaderButton action={RemoveFailedJobs} />
|
||||
</HStack>
|
||||
{/snippet}
|
||||
<AdminPageLayout
|
||||
breadcrumbs={[{ title: $t('admin.queues'), href: AppRoute.ADMIN_QUEUES }, { title: item.title }]}
|
||||
actions={[Pause, Resume, Empty, MenuItemType.Divider, RemoveFailedJobs]}
|
||||
>
|
||||
<div>
|
||||
<Container size="large" center>
|
||||
<div class="mb-1 mt-4 flex items-center gap-2">
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
import ThemeSettings from '$lib/components/admin-settings/ThemeSettings.svelte';
|
||||
import TrashSettings from '$lib/components/admin-settings/TrashSettings.svelte';
|
||||
import UserSettings from '$lib/components/admin-settings/UserSettings.svelte';
|
||||
import HeaderButton from '$lib/components/HeaderButton.svelte';
|
||||
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
|
||||
import SettingAccordionState from '$lib/components/shared-components/settings/setting-accordion-state.svelte';
|
||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||
@@ -27,7 +26,7 @@
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import { getSystemConfigActions } from '$lib/services/system-config.service';
|
||||
import { Alert, CommandPaletteContext, HStack } from '@immich/ui';
|
||||
import { Alert, CommandPaletteContext } from '@immich/ui';
|
||||
import {
|
||||
mdiAccountOutline,
|
||||
mdiBackupRestore,
|
||||
@@ -217,24 +216,13 @@
|
||||
|
||||
<CommandPaletteContext commands={[CopyToClipboard, Upload, Download]} />
|
||||
|
||||
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]}>
|
||||
{#snippet buttons()}
|
||||
<HStack gap={1}>
|
||||
<div class="hidden lg:block">
|
||||
<SearchBar placeholder={$t('search_settings')} bind:name={searchQuery} showLoadingSpinner={false} />
|
||||
</div>
|
||||
<HeaderButton action={CopyToClipboard} />
|
||||
<HeaderButton action={Download} />
|
||||
<HeaderButton action={Upload} />
|
||||
</HStack>
|
||||
{/snippet}
|
||||
|
||||
<section id="setting-content" class="flex place-content-center sm:mx-4">
|
||||
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]} actions={[CopyToClipboard, Download, Upload]}>
|
||||
<section id="setting-content" class="flex place-content-center sm:mx-4 mt-4">
|
||||
<section class="w-full pb-28 sm:w-5/6 md:w-4xl">
|
||||
{#if featureFlagsManager.value.configFile}
|
||||
<Alert color="warning" class="text-dark my-4" title={$t('admin.config_set_by_file')} />
|
||||
{/if}
|
||||
<div class="block lg:hidden">
|
||||
<div>
|
||||
<SearchBar placeholder={$t('search_settings')} bind:name={searchQuery} showLoadingSpinner={false} />
|
||||
</div>
|
||||
<SettingAccordionState queryParam={QueryParameter.IS_OPEN}>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<script lang="ts">
|
||||
import HeaderButton from '$lib/components/HeaderButton.svelte';
|
||||
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import { getUserAdminsActions, handleNavigateUserAdmin } from '$lib/services/user-admin.service';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { getByteUnitString } from '$lib/utils/byte-units';
|
||||
import { searchUsersAdmin, type UserAdminResponseDto } from '@immich/sdk';
|
||||
import { Button, CommandPaletteContext, HStack, Icon } from '@immich/ui';
|
||||
import { Button, CommandPaletteContext, Icon } from '@immich/ui';
|
||||
import { mdiInfinity } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
@@ -45,12 +44,7 @@
|
||||
|
||||
<CommandPaletteContext commands={[Create]} />
|
||||
|
||||
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]}>
|
||||
{#snippet buttons()}
|
||||
<HStack gap={1}>
|
||||
<HeaderButton action={Create} />
|
||||
</HStack>
|
||||
{/snippet}
|
||||
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]} actions={[Create]}>
|
||||
<section id="setting-content" class="flex place-content-center sm:mx-4">
|
||||
<section class="w-full pb-28 lg:w-212.5">
|
||||
<table class="my-5 w-full text-start">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import HeaderButton from '$lib/components/HeaderButton.svelte';
|
||||
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import ServerStatisticsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte';
|
||||
@@ -8,7 +7,6 @@
|
||||
import DeviceCard from '$lib/components/user-settings-page/device-card.svelte';
|
||||
import FeatureSetting from '$lib/components/users/FeatureSetting.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
|
||||
import { getUserAdminActions } from '$lib/services/user-admin.service';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { createDateFormatter, findLocale } from '$lib/utils';
|
||||
@@ -26,8 +24,8 @@
|
||||
Container,
|
||||
getByteUnitString,
|
||||
Heading,
|
||||
HStack,
|
||||
Icon,
|
||||
MenuItemType,
|
||||
Stack,
|
||||
Text,
|
||||
} from '@immich/ui';
|
||||
@@ -42,15 +40,14 @@
|
||||
mdiPlayCircle,
|
||||
mdiTrashCanOutline,
|
||||
} from '@mdi/js';
|
||||
import { DateTime } from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
data: PageData;
|
||||
}
|
||||
};
|
||||
|
||||
let { data }: Props = $props();
|
||||
const { data }: Props = $props();
|
||||
|
||||
let user = $derived(data.user);
|
||||
const userPreferences = $derived(data.userPreferences);
|
||||
@@ -94,9 +91,6 @@
|
||||
await goto(AppRoute.ADMIN_USERS);
|
||||
}
|
||||
};
|
||||
|
||||
const getDeleteDate = (deletedAt: string): Date =>
|
||||
DateTime.fromISO(deletedAt).plus({ days: serverConfigManager.value.userDeleteDelay }).toJSDate();
|
||||
</script>
|
||||
|
||||
<OnEvents
|
||||
@@ -110,19 +104,8 @@
|
||||
|
||||
<AdminPageLayout
|
||||
breadcrumbs={[{ title: $t('admin.user_management'), href: AppRoute.ADMIN_USERS }, { title: user.name }]}
|
||||
actions={[ResetPassword, ResetPinCode, Update, Restore, MenuItemType.Divider, Delete]}
|
||||
>
|
||||
{#snippet buttons()}
|
||||
<HStack gap={0}>
|
||||
<HeaderButton action={ResetPassword} />
|
||||
<HeaderButton action={ResetPinCode} />
|
||||
<HeaderButton action={Update} />
|
||||
<HeaderButton
|
||||
action={Restore}
|
||||
title={$t('admin.user_restore_scheduled_removal', { values: { date: getDeleteDate(user.deletedAt!) } })}
|
||||
/>
|
||||
<HeaderButton action={Delete} />
|
||||
</HStack>
|
||||
{/snippet}
|
||||
<div>
|
||||
<Container size="large" center>
|
||||
{#if user.deletedAt}
|
||||
|
||||
Reference in New Issue
Block a user