Compare commits

...

7 Commits

Author SHA1 Message Date
idubnori
3c80049192 chore(mobile): add kebabu menu in asset viewer (#24387)
* feat(mobile): implement viewer kebab menu with about option

* feat: revert exisitng buttons, adjust label name

* unify MenuAnchor usage

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-12-05 19:51:59 +00:00
Hai Sullivan
8f1669efbe chore(mobile): smoother UI experience for iOS devices (#24397)
allows the tab pages to use the standard Material page transition during push/pop navigation
2025-12-05 11:02:04 -06:00
Robert Schäfer
146bf65d02 refactor(dev): remove ulimits for rootless docker (#24393)
Description
-----------

When I follow the [developer setup](https://docs.immich.app/developer/setup) I run into a permission error using rootless docker. A while ago I asked on Discord in [#contributing](https://discord.com/channels/979116623879368755/1071165397228855327/1442974448776122592) about these ulimits.

I suggest to remove the `ulimits` altogether. It seems that @ItalyPaleAle has left the setting just hoping that it could help somebody in the future. See the [PR description](https://github.com/immich-app/immich/pull/4556).

How Has This Been Tested?
-------------------------

Using rootless docker:

```
$ docker context ls
NAME         DESCRIPTION                               DOCKER ENDPOINT                     ERROR
default                                                unix:///var/run/docker.sock
rootless *                                             unix:///run/user/1000/docker.sock
```

Running `make` will fail because of permission errors:
```
$  docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans
...
Error response from daemon: failed to create task for container: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: error during container init: error setting rlimits for ready process: error setting rlimit type 7: operation not permitted
```

On my machine I have the following hard limit for "Maximum number of open file descriptors":
```
$ ulimit -nH
524288
```

I can confirm that the permission error is caused by the security restrictions of the operating system mentioned above:

Changing `docker/docker-compose.dev.yml` like ..

```
    ulimits:
      nofile:
        soft: 524289
        hard: 524289
```

.. will lead to a permission error whereas this ..

```
    ulimits:
      nofile:
        soft: 524288
        hard: 524288
```

.. starts fine.

Apparently the defaults for these limits are coming from [systemd](26b2085d54/man/systemd.exec.xml (L1122)) which is used on nearly every linux distribution. So my assumption is that almost any linux user who uses rootless docker will run into a permission error when starting the development setup.

Checklist:
----------

- [x] I have performed a self-review of my own code
- [x] I have made corresponding changes to the documentation if applicable
- [x] I have no unrelated changes in the PR.
- [ ] I have confirmed that any new dependencies are strictly necessary.
- [ ] I have written tests for new code (if applicable)
- [ ] I have followed naming conventions/patterns in the surrounding code
- [ ] All code in `src/services/` uses repositories implementations for database calls, filesystem operations, etc.
- [ ] All code in `src/repositories/` is pretty basic/simple and does not have any immich specific logic (that belongs in `src/services/`)
2025-12-05 09:26:20 -05:00
Daniel Dietzler
75a7c9c06c feat: sql tools array as default value (#24389) 2025-12-04 12:54:20 -05:00
Daniel Dietzler
ae8f5a6673 fix: prettier (#24386) 2025-12-04 16:10:42 +00:00
Jason Rasmussen
31f2c7b505 feat: header context menu (#24374) 2025-12-04 11:09:38 -05:00
Yaros
ba6687dde9 feat(web): search type selection dropdown (#24091)
* feat(web): search type selection dropdown

* chore: implement suggestions

* lint

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-12-04 04:10:12 +00:00
45 changed files with 545 additions and 398 deletions

View File

@@ -4,6 +4,6 @@
"format:fix": "prettier --write ."
},
"devDependencies": {
"prettier": "^3.5.3"
"prettier": "^3.7.4"
}
}

View File

@@ -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",

View File

@@ -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:

View File

@@ -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": {

View File

@@ -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",

View File

@@ -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.",

View File

@@ -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(

View File

@@ -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(),
);
},
);

View File

@@ -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(

View File

@@ -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,
);
}

View File

@@ -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),
);

View File

@@ -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),
);

View File

@@ -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()])),

View File

@@ -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,
);
}

View File

@@ -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,
);
}

View File

@@ -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();

View File

@@ -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(),
);
},
);
}
}

View File

@@ -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
View File

@@ -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:

View File

@@ -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",

View File

@@ -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;

View File

@@ -39,6 +39,10 @@ export const fromColumnValue = (columnValue?: ColumnValue) => {
return `'${value.toISOString()}'`;
}
if (Array.isArray(value)) {
return "'{}'";
}
return `'${String(value)}'`;
};

View File

@@ -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([]);
});
});
});

View 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: [],
};

View File

@@ -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",

View 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}

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}

View File

@@ -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;
};

View File

@@ -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();

View File

@@ -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;
}
};

View File

@@ -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' },
};

View File

@@ -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;
}
};

View File

@@ -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 } };

View File

@@ -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} />

View File

@@ -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"
>

View File

@@ -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;

View File

@@ -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>

View File

@@ -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}

View File

@@ -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">

View File

@@ -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}>

View File

@@ -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">

View File

@@ -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}