Compare commits

...

25 Commits

Author SHA1 Message Date
Alex Tran 54d7b0feb9 feat: workflow template 2026-05-21 22:16:59 -05:00
Alex Tran 05334569e3 push down when dropped 2026-05-21 15:51:54 -05:00
Alex Tran c72ad193fa insert steps 2026-05-21 15:49:01 -05:00
Alex Tran 6f8fb9e42b refactor 2026-05-21 15:08:25 -05:00
Alex Tran 746599319b remove deadcode 2026-05-21 15:00:24 -05:00
Alex Tran 62578cfad5 refactor 2026-05-21 14:59:50 -05:00
Alex Tran 4d886ba0ad refactor 2026-05-21 14:39:53 -05:00
Alex Tran 3005ff0cc4 list redesign 2026-05-20 22:33:09 -05:00
Alex Tran 9ea4a03f21 drag and drop 2026-05-20 14:57:34 -05:00
Alex Tran 69143a53b7 wip 2026-05-19 16:46:16 -05:00
Alex Tran 86255f8c31 wworkflow summary styling 2026-05-19 15:36:20 -05:00
Alex Tran b3b651aec0 redesign workflow summary 2026-05-18 23:00:38 -05:00
Alex Tran 5e4b64670c wip 2026-05-18 22:09:11 -05:00
Alex Tran dd7a51a2d9 wip: redesign card flow 2026-05-18 22:04:42 -05:00
Alex Tran 646b8249ca wip: redesign card flow 2026-05-18 20:27:44 -05:00
Alex Tran 352c129d92 wip: step property badge 2026-05-18 15:58:54 -05:00
Alex Tran 3ac91797e8 wip: add back json editor 2026-05-18 15:23:07 -05:00
Alex Tran a7d634bacd wip: add back workflow summary 2026-05-18 14:58:29 -05:00
Alex Tran 11cf9ffd85 fix: get correct workflow detail 2026-05-18 14:40:03 -05:00
Alex Tran 220891d533 wip: confirm before existing and disable/enable save button condition 2026-05-18 14:04:08 -05:00
shenlong d4808fdc4d refactor: migrate album config (#28482)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-05-18 23:28:59 +05:30
renovate[bot] 7fa967a98e chore(deps): update dependency svelte to v5.55.7 [security] (#28434)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-05-18 17:42:01 +00:00
shenlong 9cffcc9f4e refactor: migrate network config (#28471) 2026-05-18 16:22:42 +00:00
shenlong 40925f0a06 refactor: immich form and text input (#28479)
refacotr: immich form

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-05-18 16:21:36 +00:00
Oliver Roed Schøler 0544d22902 feat: Selectable metadata in duplicates utility with diffing (#26328) 2026-05-18 17:49:51 +02:00
47 changed files with 1913 additions and 879 deletions
+15 -1
View File
@@ -897,6 +897,7 @@
"date_of_birth": "Date of birth",
"date_of_birth_saved": "Date of birth saved successfully",
"date_range": "Date range",
"date_time_original": "Date/Time Original",
"day": "Day",
"days": "Days",
"deduplicate_all": "Deduplicate All",
@@ -975,6 +976,7 @@
"downloading_asset_filename": "Downloading asset {filename}",
"downloading_from_icloud": "Downloading from iCloud",
"downloading_media": "Downloading media",
"drag_to_reorder": "Drag to reorder",
"drop_files_to_upload": "Drop files anywhere to upload",
"duplicates": "Duplicates",
"duplicates_description": "Resolve each group by indicating which, if any, are duplicates.",
@@ -1197,11 +1199,13 @@
"export_as_json": "Export as JSON",
"export_database": "Export Database",
"export_database_description": "Export the SQLite database",
"exposure_time": "Exposure Time",
"extension": "Extension",
"external": "External",
"external_libraries": "External Libraries",
"external_network": "External network",
"external_network_sheet_info": "When not on the preferred Wi-Fi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom",
"f_number": "F-Number",
"face_unassigned": "Unassigned",
"failed": "Failed",
"failed_count": "Failed: {count}",
@@ -1219,7 +1223,6 @@
"features_setting_description": "Manage the app features",
"file_name_or_extension": "File name or extension",
"file_name_text": "File name",
"file_name_with_value": "File name: {file_name}",
"file_size": "File size",
"filename": "Filename",
"filetype": "Filetype",
@@ -1232,6 +1235,7 @@
"find_them_fast": "Find them fast by name with search",
"first": "First",
"fix_incorrect_match": "Fix incorrect match",
"focal_length": "Focal Length",
"folder": "Folder",
"folder_not_found": "Folder not found",
"folders": "Folders",
@@ -1352,6 +1356,7 @@
"ios_debug_info_no_sync_yet": "No background sync job has run yet",
"ios_debug_info_processes_queued": "{count, plural, one {{count} background process queued} other {{count} background processes queued}}",
"ios_debug_info_processing_ran_at": "Processing ran {dateTime}",
"iso": "ISO",
"items_count": "{count, plural, one {# item} other {# items}}",
"jobs": "Jobs",
"json_editor": "JSON editor",
@@ -1584,6 +1589,7 @@
"mobile_app": "Mobile App",
"mobile_app_download_onboarding_note": "Download the companion mobile app using the following options",
"model": "Model",
"modify_date": "Modify Date",
"month": "Month",
"more": "More",
"motion": "Motion",
@@ -1706,6 +1712,7 @@
"organize_into_albums": "Organize into albums",
"organize_into_albums_description": "Put existing photos into albums using current sync settings",
"organize_your_library": "Organize your library",
"orientation": "Orientation",
"original": "original",
"other": "Other",
"other_devices": "Other devices",
@@ -1820,6 +1827,7 @@
"profile_drawer_readonly_mode": "Read-only mode enabled. Long-press the user avatar icon to exit.",
"profile_image_of_user": "Profile image of {user}",
"profile_picture_set": "Profile picture set.",
"projection_type": "Projection Type",
"public_album": "Public album",
"public_share": "Public Share",
"purchase_account_info": "Supporter",
@@ -2189,7 +2197,9 @@
"show_in_timeline": "Show in timeline",
"show_in_timeline_setting_description": "Show photos and videos from this user in your timeline",
"show_keyboard_shortcuts": "Show keyboard shortcuts",
"show_less": "Show less",
"show_metadata": "Show metadata",
"show_more_fields": "{count, plural, one {Show # more field} other {Show # more fields}}",
"show_or_hide_info": "Show or hide info",
"show_password": "Show password",
"show_person_options": "Show person options",
@@ -2245,6 +2255,7 @@
"step_delete_confirm": "Are you sure you want to delete this step?",
"step_details": "Step details",
"steps": "Steps",
"steps_count": "{count, plural, one {# step} other {# steps}}",
"stop_casting": "Stop casting",
"stop_motion_photo": "Stop Motion Photo",
"stop_photo_sharing": "Stop sharing your photos?",
@@ -2406,6 +2417,7 @@
"use_browser_locale_description": "Format dates, times, and numbers based on your browser locale",
"use_current_connection": "Use current connection",
"use_custom_date_range": "Use custom date range instead",
"use_template": "Use template",
"user": "User",
"user_has_been_deleted": "This user has been deleted.",
"user_id": "User ID",
@@ -2467,6 +2479,7 @@
"week": "Week",
"welcome": "Welcome",
"welcome_to_immich": "Welcome to Immich",
"when": "When",
"width": "Width",
"wifi_name": "Wi-Fi Name",
"workflow": "Workflow",
@@ -2479,6 +2492,7 @@
"workflow_name": "Workflow name",
"workflow_navigation_prompt": "Are you sure you want to leave without saving your changes?",
"workflow_summary": "Workflow summary",
"workflow_templates": "Workflow templates",
"workflow_update_success": "Workflow updated successfully",
"workflow_updated": "Workflow updated",
"workflows": "Workflows",
@@ -0,0 +1,26 @@
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
class AlbumConfig {
final AlbumSortMode sortMode;
final bool isReverse;
final bool isGrid;
const AlbumConfig({this.sortMode = AlbumSortMode.mostRecent, this.isReverse = true, this.isGrid = false});
AlbumConfig copyWith({AlbumSortMode? sortMode, bool? isReverse, bool? isGrid}) => AlbumConfig(
sortMode: sortMode ?? this.sortMode,
isReverse: isReverse ?? this.isReverse,
isGrid: isGrid ?? this.isGrid,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is AlbumConfig && other.sortMode == sortMode && other.isReverse == isReverse && other.isGrid == isGrid);
@override
int get hashCode => Object.hash(sortMode, isReverse, isGrid);
@override
String toString() => 'AlbumConfig(sortMode: $sortMode, isReverse: $isReverse, isGrid: $isGrid)';
}
@@ -1,10 +1,11 @@
import 'package:immich_mobile/domain/models/config/album_config.dart';
import 'package:immich_mobile/domain/models/config/cleanup_config.dart';
import 'package:immich_mobile/domain/models/config/image_config.dart';
import 'package:immich_mobile/domain/models/config/map_config.dart';
import 'package:immich_mobile/domain/models/config/slideshow_config.dart';
import 'package:immich_mobile/domain/models/config/theme_config.dart';
import 'package:immich_mobile/domain/models/config/timeline_config.dart';
import 'package:immich_mobile/domain/models/config/viewer_config.dart';
import 'package:immich_mobile/domain/models/config/slideshow_config.dart';
class AppConfig {
final ThemeConfig theme;
@@ -14,6 +15,7 @@ class AppConfig {
final ImageConfig image;
final ViewerConfig viewer;
final SlideshowConfig slideshow;
final AlbumConfig album;
const AppConfig({
this.theme = const .new(),
@@ -23,6 +25,7 @@ class AppConfig {
this.image = const .new(),
this.viewer = const .new(),
this.slideshow = const .new(),
this.album = const .new(),
});
AppConfig copyWith({
@@ -33,6 +36,7 @@ class AppConfig {
ImageConfig? image,
ViewerConfig? viewer,
SlideshowConfig? slideshow,
AlbumConfig? album,
}) => .new(
theme: theme ?? this.theme,
cleanup: cleanup ?? this.cleanup,
@@ -41,6 +45,7 @@ class AppConfig {
image: image ?? this.image,
viewer: viewer ?? this.viewer,
slideshow: slideshow ?? this.slideshow,
album: album ?? this.album,
);
@override
@@ -53,12 +58,13 @@ class AppConfig {
other.timeline == timeline &&
other.image == image &&
other.viewer == viewer &&
other.slideshow == slideshow);
other.slideshow == slideshow &&
other.album == album);
@override
int get hashCode => Object.hash(theme, cleanup, map, timeline, image, viewer, slideshow);
int get hashCode => Object.hash(theme, cleanup, map, timeline, image, viewer, slideshow, album);
@override
String toString() =>
'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow)';
'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album)';
}
@@ -0,0 +1,54 @@
import 'package:flutter/foundation.dart';
class NetworkConfig {
final bool autoEndpointSwitching;
final String? preferredWifiName;
final String? localEndpoint;
final List<String> externalEndpointList;
final Map<String, String> customHeaders;
const NetworkConfig({
this.autoEndpointSwitching = false,
this.preferredWifiName,
this.localEndpoint,
this.externalEndpointList = const [],
this.customHeaders = const {},
});
NetworkConfig copyWith({
bool? autoEndpointSwitching,
String? preferredWifiName,
String? localEndpoint,
List<String>? externalEndpointList,
Map<String, String>? customHeaders,
}) => NetworkConfig(
autoEndpointSwitching: autoEndpointSwitching ?? this.autoEndpointSwitching,
preferredWifiName: preferredWifiName ?? this.preferredWifiName,
localEndpoint: localEndpoint ?? this.localEndpoint,
externalEndpointList: externalEndpointList ?? this.externalEndpointList,
customHeaders: customHeaders ?? this.customHeaders,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is NetworkConfig &&
other.autoEndpointSwitching == autoEndpointSwitching &&
other.preferredWifiName == preferredWifiName &&
other.localEndpoint == localEndpoint &&
listEquals(other.externalEndpointList, externalEndpointList) &&
mapEquals(other.customHeaders, customHeaders));
@override
int get hashCode => Object.hash(
autoEndpointSwitching,
preferredWifiName,
localEndpoint,
Object.hashAll(externalEndpointList),
Object.hashAllUnordered(customHeaders.entries.map((e) => Object.hash(e.key, e.value))),
);
@override
String toString() =>
'NetworkConfig(autoEndpointSwitching: $autoEndpointSwitching, preferredWifiName: $preferredWifiName, localEndpoint: $localEndpoint, externalEndpointList: $externalEndpointList, customHeaders: $customHeaders)';
}
@@ -1,18 +1,22 @@
import 'package:immich_mobile/domain/models/config/network_config.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
class SystemConfig {
final LogLevel logLevel;
final NetworkConfig network;
const SystemConfig({this.logLevel = .info});
const SystemConfig({this.logLevel = .info, this.network = const .new()});
SystemConfig copyWith({LogLevel? logLevel}) => SystemConfig(logLevel: logLevel ?? this.logLevel);
SystemConfig copyWith({LogLevel? logLevel, NetworkConfig? network}) =>
SystemConfig(logLevel: logLevel ?? this.logLevel, network: network ?? this.network);
@override
bool operator ==(Object other) => identical(this, other) || (other is SystemConfig && other.logLevel == logLevel);
bool operator ==(Object other) =>
identical(this, other) || (other is SystemConfig && other.logLevel == logLevel && other.network == network);
@override
int get hashCode => logLevel.hashCode;
int get hashCode => Object.hash(logLevel, network);
@override
String toString() => 'SystemConfig(logLevel: $logLevel)';
String toString() => 'SystemConfig(logLevel: $logLevel, network: $network)';
}
@@ -8,6 +8,7 @@ import 'package:immich_mobile/domain/models/config/app_config.dart';
import 'package:immich_mobile/domain/models/config/system_config.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
enum MetadataDomain<T extends Object> {
appConfig<AppConfig>('config.app'),
@@ -34,6 +35,33 @@ enum MetadataKey<T extends Object> {
viewerAutoPlayVideo<bool>(.appConfig, 'viewer.autoPlayVideo', true),
viewerTapToNavigate<bool>(.appConfig, 'viewer.tapToNavigate', false),
// Network
networkAutoEndpointSwitching<bool>(.systemConfig, 'network.autoEndpointSwitching', false),
networkPreferredWifiName<String>(.systemConfig, 'network.preferredWifiName', ''),
networkLocalEndpoint<String>(.systemConfig, 'network.localEndpoint', ''),
networkExternalEndpointList<List<String>>(
.systemConfig,
'network.externalEndpointList',
[],
_ListCodec(_PrimitiveCodec.string),
),
networkCustomHeaders<Map<String, String>>(
.systemConfig,
'network.customHeaders',
{},
_MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string),
),
// Album
albumSortMode<AlbumSortMode>(
.appConfig,
'album.sortMode',
AlbumSortMode.mostRecent,
_EnumCodec(AlbumSortMode.values),
),
albumIsReverse<bool>(.appConfig, 'album.isReverse', true),
albumIsGrid<bool>(.appConfig, 'album.isGrid', false),
// Timeline
timelineTilesPerRow<int>(.appConfig, 'timeline.tilesPerRow', 4),
timelineGroupAssetsBy<GroupAssetsBy>(
@@ -143,6 +171,47 @@ final class _DateTimeCodec extends _MetadataCodec<DateTime> {
DateTime? decode(String raw) => DateTime.tryParse(raw);
}
final class _MapCodec<K extends Object, V extends Object> extends _MetadataCodec<Map<K, V>> {
final _MetadataCodec<K> _keyCodec;
final _MetadataCodec<V> _valueCodec;
const _MapCodec(this._keyCodec, this._valueCodec);
@override
String encode(Map<K, V> value) {
final entries = <String, String>{};
value.forEach((k, v) => entries[_keyCodec.encode(k)] = _valueCodec.encode(v));
return jsonEncode(entries);
}
@override
Map<K, V>? decode(String raw) {
try {
final decoded = jsonDecode(raw);
if (decoded is! Map) {
return null;
}
final result = <K, V>{};
for (final entry in decoded.entries) {
final rawKey = entry.key;
final rawValue = entry.value;
if (rawKey is! String || rawValue is! String) {
return null;
}
final k = _keyCodec.decode(rawKey);
final v = _valueCodec.decode(rawValue);
if (k == null || v == null) {
return null;
}
result[k] = v;
}
return result;
} on FormatException {
return null;
}
}
}
final class _ListCodec<T extends Object> extends _MetadataCodec<List<T>> {
final _MetadataCodec<T> _elementCodec;
+8 -13
View File
@@ -11,26 +11,13 @@ enum StoreKey<T> {
serverUrl<String>._(10),
accessToken<String>._(11),
serverEndpoint<String>._(12),
selectedAlbumSortOrder<int>._(113),
advancedTroubleshooting<bool>._(114),
selectedAlbumSortReverse<bool>._(123),
enableHapticFeedback<bool>._(126),
customHeaders<String>._(127),
syncAlbums<bool>._(131),
// Auto endpoint switching
autoEndpointSwitching<bool>._(132),
preferredWifiName<String>._(133),
localEndpoint<String>._(134),
externalEndpointList<String>._(135),
manageLocalMediaAndroid<bool>._(137),
// Read-only Mode settings
readonlyModeEnabled<bool>._(138),
albumGridView<bool>._(140),
// Image viewer navigation settings
tapToNavigate<bool>._(141),
// Experimental stuff
enableBackup<bool>._(1003),
@@ -39,6 +26,14 @@ enum StoreKey<T> {
syncMigrationStatus<String>._(1013),
// Legacy keys that have been migrated to the new metadata store
legacySelectedAlbumSortOrder<int>._(113),
legacySelectedAlbumSortReverse<bool>._(123),
legacyAlbumGridView<bool>._(140),
legacyAutoEndpointSwitching<bool>._(132),
legacyPreferredWifiName<String>._(133),
legacyLocalEndpoint<String>._(134),
legacyExternalEndpointList<String>._(135),
legacyCustomHeaders<String>._(127),
legacyLoopVideo<bool>._(117),
legacyLoadOriginalVideo<bool>._(136),
legacyAutoPlayVideo<bool>._(139),
@@ -4,6 +4,8 @@ extension StringExtension on String {
String capitalize() {
return split(" ").map((str) => str.isEmpty ? str : str[0].toUpperCase() + str.substring(1)).join(" ");
}
String? get nullIfEmpty => isEmpty ? null : this;
}
extension DurationExtension on String {
@@ -2,6 +2,7 @@ import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/config/app_config.dart';
import 'package:immich_mobile/domain/models/config/system_config.dart';
import 'package:immich_mobile/domain/models/metadata_key.dart';
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/infrastructure/entities/metadata.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
@@ -146,9 +147,23 @@ extension<T extends Object> on MetadataDomain<T> {
look: repo._read(.slideshowLook),
direction: repo._read(.slideshowDirection),
),
album: .new(
sortMode: repo._read(.albumSortMode),
isReverse: repo._read(.albumIsReverse),
isGrid: repo._read(.albumIsGrid),
),
);
case .systemConfig:
repo._systemConfig = .new(logLevel: repo._read(.logLevel));
repo._systemConfig = .new(
logLevel: repo._read(.logLevel),
network: .new(
autoEndpointSwitching: repo._read(.networkAutoEndpointSwitching),
preferredWifiName: repo._read(.networkPreferredWifiName).nullIfEmpty,
localEndpoint: repo._read(.networkLocalEndpoint).nullIfEmpty,
externalEndpointList: repo._read(.networkExternalEndpointList),
customHeaders: repo._read(.networkCustomHeaders),
),
);
}
}
}
@@ -1,14 +1,12 @@
import 'dart:convert';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/domain/models/metadata_key.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
class SettingsHeader {
String key = "";
@@ -24,17 +22,14 @@ class HeaderSettingsPage extends HookConsumerWidget {
final headers = useState<List<SettingsHeader>>([]);
final setInitialHeaders = useState(false);
var headersStr = Store.get(StoreKey.customHeaders, "");
final storedHeaders = ref.read(metadataProvider).systemConfig.network.customHeaders;
if (!setInitialHeaders.value) {
if (headersStr.isNotEmpty) {
var customHeaders = jsonDecode(headersStr) as Map;
customHeaders.forEach((k, v) {
final header = SettingsHeader();
header.key = k;
header.value = v;
headers.value.add(header);
});
}
storedHeaders.forEach((k, v) {
final header = SettingsHeader();
header.key = k;
header.value = v;
headers.value.add(header);
});
// add first one to help the user
if (headers.value.isEmpty) {
@@ -88,8 +83,8 @@ class HeaderSettingsPage extends HookConsumerWidget {
}
saveHeaders(WidgetRef ref, List<SettingsHeader> headers) async {
final headersMap = {};
for (var header in headers) {
final headersMap = <String, String>{};
for (final header in headers) {
final key = header.key.trim();
final value = header.value.trim();
@@ -99,8 +94,7 @@ class HeaderSettingsPage extends HookConsumerWidget {
headersMap[key] = value;
}
var encoded = jsonEncode(headersMap);
await Store.put(StoreKey.customHeaders, encoded);
await ref.read(metadataProvider).write(MetadataKey.networkCustomHeaders, headersMap);
await ref.read(apiServiceProvider).updateHeaders();
}
}
@@ -15,15 +15,15 @@ import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
import 'package:immich_mobile/presentation/widgets/album/new_album_name_modal.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/domain/models/metadata_key.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/album_filter.utils.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -58,19 +58,11 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final appSettings = ref.read(appSettingsServiceProvider);
final savedSortMode = appSettings.getSetting(AppSettingsEnum.selectedAlbumSortOrder);
final savedIsReverse = appSettings.getSetting(AppSettingsEnum.selectedAlbumSortReverse);
final savedIsGrid = appSettings.getSetting(AppSettingsEnum.albumGridView);
final albumSortMode = AlbumSortMode.values.firstWhere(
(e) => e.storeIndex == savedSortMode,
orElse: () => AlbumSortMode.lastModified,
);
final albumConfig = ref.read(metadataProvider).appConfig.album;
setState(() {
sort = AlbumSort(mode: albumSortMode, isReverse: savedIsReverse);
isGrid = savedIsGrid;
sort = AlbumSort(mode: albumConfig.sortMode, isReverse: albumConfig.isReverse);
isGrid = albumConfig.isGrid;
});
ref.read(remoteAlbumProvider.notifier).refresh();
@@ -102,7 +94,7 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
setState(() {
isGrid = !isGrid;
});
ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.albumGridView, isGrid);
ref.read(metadataProvider).write(MetadataKey.albumIsGrid, isGrid);
}
void changeFilter(QuickFilterMode mode) {
@@ -118,9 +110,9 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
this.sort = sort;
});
final appSettings = ref.read(appSettingsServiceProvider);
await appSettings.setSetting(AppSettingsEnum.selectedAlbumSortOrder, sort.mode.storeIndex);
await appSettings.setSetting(AppSettingsEnum.selectedAlbumSortReverse, sort.isReverse);
final metadata = ref.read(metadataProvider);
await metadata.write(MetadataKey.albumSortMode, sort.mode);
await metadata.write(MetadataKey.albumIsReverse, sort.isReverse);
await sortAlbums();
}
+10 -5
View File
@@ -1,6 +1,9 @@
import 'dart:convert';
import 'package:flutter_udid/flutter_udid.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/metadata_key.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/user.service.dart';
@@ -8,6 +11,7 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/models/auth/auth_state.model.dart';
import 'package:immich_mobile/models/auth/login_response.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/auth.service.dart';
@@ -126,7 +130,8 @@ class AuthNotifier extends StateNotifier<AuthState> {
await _apiService.updateHeaders();
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final customHeaders = Store.tryGet(StoreKey.customHeaders);
final headerMap = _ref.read(metadataProvider).systemConfig.network.customHeaders;
final customHeaders = headerMap.isEmpty ? null : jsonEncode(headerMap);
await _widgetService.writeCredentials(serverEndpoint, accessToken, customHeaders);
// Get the deviceid from the store if it exists, otherwise generate a new one
@@ -174,19 +179,19 @@ class AuthNotifier extends StateNotifier<AuthState> {
}
Future<void> saveWifiName(String wifiName) async {
await Store.put(StoreKey.preferredWifiName, wifiName);
await _ref.read(metadataProvider).write(MetadataKey.networkPreferredWifiName, wifiName);
}
Future<void> saveLocalEndpoint(String url) async {
await Store.put(StoreKey.localEndpoint, url);
await _ref.read(metadataProvider).write(MetadataKey.networkLocalEndpoint, url);
}
String? getSavedWifiName() {
return Store.tryGet(StoreKey.preferredWifiName);
return _ref.read(metadataProvider).systemConfig.network.preferredWifiName;
}
String? getSavedLocalEndpoint() {
return Store.tryGet(StoreKey.localEndpoint);
return _ref.read(metadataProvider).systemConfig.network.localEndpoint;
}
/// Returns the current server endpoint (with /api) URL from the store
+13 -19
View File
@@ -1,46 +1,40 @@
import 'dart:convert';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
final authRepositoryProvider = Provider<AuthRepository>((ref) => AuthRepository(ref.watch(driftProvider)));
final authRepositoryProvider = Provider<AuthRepository>(
(ref) => AuthRepository(ref.watch(driftProvider), ref.watch(metadataProvider)),
);
class AuthRepository {
final Drift _drift;
final MetadataRepository _metadata;
const AuthRepository(this._drift);
const AuthRepository(this._drift, this._metadata);
Future<void> clearLocalData() async {
await SyncStreamRepository(_drift).reset();
}
bool getEndpointSwitchingFeature() {
return Store.tryGet(StoreKey.autoEndpointSwitching) ?? false;
return _metadata.systemConfig.network.autoEndpointSwitching;
}
String? getPreferredWifiName() {
return Store.tryGet(StoreKey.preferredWifiName);
return _metadata.systemConfig.network.preferredWifiName;
}
String? getLocalEndpoint() {
return Store.tryGet(StoreKey.localEndpoint);
return _metadata.systemConfig.network.localEndpoint;
}
List<AuxilaryEndpoint> getExternalEndpointList() {
final jsonString = Store.tryGet(StoreKey.externalEndpointList);
if (jsonString == null) {
return [];
}
final List<dynamic> jsonList = jsonDecode(jsonString);
final endpointList = jsonList.map((e) => AuxilaryEndpoint.fromJson(e)).toList();
return endpointList;
return _metadata.systemConfig.network.externalEndpointList
.map((url) => AuxilaryEndpoint(url: url, status: .valid))
.toList();
}
}
+8 -17
View File
@@ -5,8 +5,8 @@ import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:logging/logging.dart';
@@ -177,30 +177,21 @@ class ApiService {
if (serverEndpoint != null && serverEndpoint.isNotEmpty) {
urls.add(serverEndpoint);
}
final localEndpoint = Store.tryGet(StoreKey.localEndpoint);
if (localEndpoint != null && localEndpoint.isNotEmpty) {
final network = MetadataRepository.instance.systemConfig.network;
final localEndpoint = network.localEndpoint;
if (localEndpoint != null) {
urls.add(localEndpoint);
}
final externalJson = Store.tryGet(StoreKey.externalEndpointList);
if (externalJson != null) {
final List<dynamic> list = jsonDecode(externalJson);
for (final entry in list) {
final url = AuxilaryEndpoint.fromJson(entry).url;
if (url.isNotEmpty) {
urls.add(url);
}
for (final url in network.externalEndpointList) {
if (url.isNotEmpty) {
urls.add(url);
}
}
return urls;
}
static Map<String, String> getRequestHeaders() {
var customHeadersStr = Store.get(StoreKey.customHeaders, "");
if (customHeadersStr.isEmpty) {
return const {};
}
return (jsonDecode(customHeadersStr) as Map).cast<String, String>();
return MetadataRepository.instance.systemConfig.network.customHeaders;
}
ApiClient get apiClient => _apiClient;
@@ -2,18 +2,14 @@ import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
enum AppSettingsEnum<T> {
selectedAlbumSortOrder<int>(StoreKey.selectedAlbumSortOrder, "selectedAlbumSortOrder", 2),
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false),
selectedAlbumSortReverse<bool>(StoreKey.selectedAlbumSortReverse, null, true),
enableHapticFeedback<bool>(StoreKey.enableHapticFeedback, null, true),
syncAlbums<bool>(StoreKey.syncAlbums, null, false),
autoEndpointSwitching<bool>(StoreKey.autoEndpointSwitching, null, false),
enableBackup<bool>(StoreKey.enableBackup, null, false),
useCellularForUploadVideos<bool>(StoreKey.useWifiForUploadVideos, null, false),
useCellularForUploadPhotos<bool>(StoreKey.useWifiForUploadPhotos, null, false),
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false),
albumGridView<bool>(StoreKey.albumGridView, "albumGridView", false),
backupRequireCharging<bool>(StoreKey.backupRequireCharging, null, false),
backupTriggerDelay<int>(StoreKey.backupTriggerDelay, null, 30);
-4
View File
@@ -123,10 +123,6 @@ class AuthService {
_authRepository.clearLocalData(),
Store.delete(StoreKey.currentUser),
Store.delete(StoreKey.accessToken),
Store.delete(StoreKey.autoEndpointSwitching),
Store.delete(StoreKey.preferredWifiName),
Store.delete(StoreKey.localEndpoint),
Store.delete(StoreKey.externalEndpointList),
]);
}
+116 -12
View File
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:flutter/material.dart';
@@ -12,7 +13,8 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/metadata.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
const int targetVersion = 26;
@@ -37,12 +39,35 @@ Future<void> _migrateTo25() async {
return;
}
final serverUrls = ApiService.getServerUrls();
if (serverUrls.isEmpty) {
final urls = <String>[];
final serverEndpoint = Store.tryGet(StoreKey.serverEndpoint);
if (serverEndpoint != null && serverEndpoint.isNotEmpty) {
urls.add(serverEndpoint);
}
final localEndpoint = Store.tryGet(StoreKey.legacyLocalEndpoint);
if (localEndpoint != null && localEndpoint.isNotEmpty) {
urls.add(localEndpoint);
}
final externalJson = Store.tryGet(StoreKey.legacyExternalEndpointList);
if (externalJson != null) {
final List<dynamic> list = jsonDecode(externalJson);
for (final entry in list) {
final url = AuxilaryEndpoint.fromJson(entry).url;
if (url.isNotEmpty) {
urls.add(url);
}
}
}
if (urls.isEmpty) {
return;
}
await NetworkRepository.setHeaders(ApiService.getRequestHeaders(), serverUrls, token: accessToken);
final customHeadersStr = Store.get(StoreKey.legacyCustomHeaders, "");
final headers = customHeadersStr.isEmpty
? const <String, String>{}
: (jsonDecode(customHeadersStr) as Map).cast<String, String>();
await NetworkRepository.setHeaders(headers, urls, token: accessToken);
}
Future<void> _migrateTo26(Drift drift) async {
@@ -57,14 +82,7 @@ Future<void> _migrateTo26(Drift drift) async {
final cleanupKeepAlbumIds = await migrator.readLegacyStoreString(StoreKey.legacyCleanupKeepAlbumIds.id);
if (cleanupKeepAlbumIds != null) {
final ids = cleanupKeepAlbumIds.split(',').where((id) => id.isNotEmpty).toList();
await drift.metadataEntity.insertOnConflictUpdate(
MetadataEntityCompanion.insert(
key: MetadataKey.cleanupKeepAlbumIds.key,
value: MetadataKey.cleanupKeepAlbumIds.encode(ids),
updatedAt: Value(DateTime.now()),
),
);
await migrator.deleteLegacyStoreRows([StoreKey.legacyCleanupKeepAlbumIds.id]);
migrator.stage(StoreKey.legacyCleanupKeepAlbumIds, MetadataKey.cleanupKeepAlbumIds, ids);
}
await migrator.migrateBool(StoreKey.legacyCleanupKeepFavorites, MetadataKey.cleanupKeepFavorites);
await migrator.migrateEnumIndex(
@@ -96,9 +114,80 @@ Future<void> _migrateTo26(Drift drift) async {
await migrator.migrateBool(StoreKey.legacyLoadOriginalVideo, MetadataKey.viewerLoadOriginalVideo);
await migrator.migrateBool(StoreKey.legacyAutoPlayVideo, MetadataKey.viewerAutoPlayVideo);
await migrator.migrateBool(StoreKey.legacyTapToNavigate, MetadataKey.viewerTapToNavigate);
// Network
await migrator.migrateBool(StoreKey.legacyAutoEndpointSwitching, MetadataKey.networkAutoEndpointSwitching);
await migrator.migrateString(StoreKey.legacyPreferredWifiName, MetadataKey.networkPreferredWifiName);
await migrator.migrateString(StoreKey.legacyLocalEndpoint, MetadataKey.networkLocalEndpoint);
await _migrateExternalEndpointList(migrator);
await _migrateCustomHeaders(migrator);
// Album
await _migrateAlbumSortMode(migrator);
await migrator.migrateBool(StoreKey.legacySelectedAlbumSortReverse, MetadataKey.albumIsReverse);
await migrator.migrateBool(StoreKey.legacyAlbumGridView, MetadataKey.albumIsGrid);
await migrator.complete();
}
Future<void> _migrateAlbumSortMode(_StoreMigrator migrator) async {
final raw = await migrator.readLegacyStoreInt(StoreKey.legacySelectedAlbumSortOrder.id);
if (raw == null) {
return;
}
final mode = AlbumSortMode.values.firstWhere(
(e) => e.storeIndex == raw,
orElse: () => MetadataKey.albumSortMode.defaultValue,
);
migrator.stage(StoreKey.legacySelectedAlbumSortOrder, MetadataKey.albumSortMode, mode);
}
Future<void> _migrateExternalEndpointList(_StoreMigrator migrator) async {
final raw = await migrator.readLegacyStoreString(StoreKey.legacyExternalEndpointList.id);
if (raw == null) {
return;
}
final urls = <String>[];
try {
final decoded = jsonDecode(raw);
if (decoded is List) {
for (final entry in decoded) {
final url = AuxilaryEndpoint.fromJson(entry).url;
if (url.isNotEmpty) {
urls.add(url);
}
}
}
} on FormatException {
// ignore invalid entries
}
migrator.stage(StoreKey.legacyExternalEndpointList, MetadataKey.networkExternalEndpointList, urls);
}
Future<void> _migrateCustomHeaders(_StoreMigrator migrator) async {
final raw = await migrator.readLegacyStoreString(StoreKey.legacyCustomHeaders.id);
if (raw == null) {
return;
}
final headers = <String, String>{};
try {
final decoded = jsonDecode(raw);
if (decoded is Map) {
decoded.forEach((key, value) {
if (key is String && value is String) {
headers[key] = value;
}
});
}
} on FormatException {
// ignore invalid entries
}
migrator.stage(StoreKey.legacyCustomHeaders, MetadataKey.networkCustomHeaders, headers);
}
class _StoreMigrator {
final Drift _db;
final Map<MetadataKey<Object>, Object> _cache = {};
@@ -153,6 +242,21 @@ class _StoreMigrator {
_migratedStoreIds.add(legacyKey.id);
}
Future<void> migrateString(StoreKey<String> legacyKey, MetadataKey<String> newKey) async {
final value = await readLegacyStoreString(legacyKey.id);
if (value == null) {
return;
}
_cache[newKey] = value;
_migratedStoreIds.add(legacyKey.id);
}
void stage<T extends Object>(StoreKey legacyKey, MetadataKey<T> newKey, T value) {
_cache[newKey] = value;
_migratedStoreIds.add(legacyKey.id);
}
Future<void> complete() async {
await _db.batch((batch) {
for (final entry in _cache.entries) {
@@ -397,16 +397,16 @@ class LoginForm extends HookConsumerWidget {
mainAxisSize: MainAxisSize.max,
children: [
ImmichForm(
onSubmit: getServerAuthSettings,
submitText: 'next'.t(context: context),
submitIcon: Icons.arrow_forward_rounded,
onSubmit: getServerAuthSettings,
child: ImmichURLInput(
builder: (_, form) => ImmichURLInput(
controller: serverEndpointController,
label: 'login_form_endpoint_url'.t(context: context),
hintText: 'login_form_endpoint_hint'.t(context: context),
validator: _validateUrl,
keyboardAction: .next,
onSubmit: (ctx, _) => ImmichForm.of(ctx).submit(),
onSubmit: (_) => form.submit(),
),
),
ImmichTextButton(
@@ -434,10 +434,10 @@ class LoginForm extends HookConsumerWidget {
),
if (isPasswordLoginEnable.value)
ImmichForm(
onSubmit: login,
submitText: 'login'.t(context: context),
submitIcon: Icons.login_rounded,
onSubmit: login,
child: Column(
builder: (context, form) => Column(
spacing: ImmichSpacing.md,
children: [
ImmichTextInput(
@@ -448,7 +448,7 @@ class LoginForm extends HookConsumerWidget {
keyboardAction: TextInputAction.next,
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
onSubmit: (_, _) => passwordFocusNode.requestFocus(),
onSubmit: (_) => passwordFocusNode.requestFocus(),
),
ImmichPasswordInput(
controller: passwordController,
@@ -456,17 +456,17 @@ class LoginForm extends HookConsumerWidget {
label: 'password'.t(context: context),
hintText: 'login_form_password_hint'.t(context: context),
keyboardAction: TextInputAction.go,
onSubmit: (ctx, _) => ImmichForm.of(ctx).submit(),
onSubmit: (_) => form.submit(),
),
],
),
),
if (isOauthEnable.value)
ImmichForm(
onSubmit: oAuthLogin,
submitText: oAuthButtonLabel.value,
submitIcon: Icons.pin_outlined,
onSubmit: oAuthLogin,
child: isPasswordLoginEnable.value
builder: (context, _) => isPasswordLoginEnable.value
? Padding(
padding: const EdgeInsets.only(left: 18.0, right: 18.0, top: 12.0),
child: Divider(color: context.isDarkTheme ? Colors.white : Colors.black, height: 5),
@@ -1,13 +1,11 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/domain/models/metadata_key.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/widgets/settings/networking_settings/endpoint_input.dart';
class ExternalNetworkPreference extends HookConsumerWidget {
@@ -23,11 +21,12 @@ class ExternalNetworkPreference extends HookConsumerWidget {
saveEndpointList() {
canSave.value = entries.value.every((e) => e.status == AuxCheckStatus.valid);
final endpointList = entries.value.where((url) => url.status == AuxCheckStatus.valid).toList();
final urls = entries.value
.where((e) => e.status == AuxCheckStatus.valid && e.url.isNotEmpty)
.map((e) => e.url)
.toList();
final jsonString = jsonEncode(endpointList);
Store.put(StoreKey.externalEndpointList, jsonString);
ref.read(metadataProvider).write(MetadataKey.networkExternalEndpointList, urls);
}
updateValidationStatus(String url, int index, AuxCheckStatus status) {
@@ -69,14 +68,13 @@ class ExternalNetworkPreference extends HookConsumerWidget {
}
useEffect(() {
final jsonString = Store.tryGet(StoreKey.externalEndpointList);
final urls = ref.read(metadataProvider).systemConfig.network.externalEndpointList;
if (jsonString == null) {
if (urls.isEmpty) {
return null;
}
final List<dynamic> jsonList = jsonDecode(jsonString);
entries.value = jsonList.map((e) => AuxilaryEndpoint.fromJson(e)).toList();
entries.value = urls.map((url) => AuxilaryEndpoint(url: url, status: .valid)).toList();
return null;
}, const []);
@@ -1,13 +1,12 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/network.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:immich_mobile/widgets/settings/networking_settings/external_network_preference.dart';
import 'package:immich_mobile/widgets/settings/networking_settings/local_network_preference.dart';
@@ -20,7 +19,10 @@ class NetworkingSettings extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentEndpoint = getServerUrl();
final featureEnabled = useAppSettingsState(AppSettingsEnum.autoEndpointSwitching);
final featureEnabled = useState(ref.read(systemConfigProvider).network.autoEndpointSwitching);
useValueChanged<bool, void>(featureEnabled.value, (_, __) {
ref.read(metadataProvider).write(.networkAutoEndpointSwitching, featureEnabled.value);
});
Future<void> checkWifiReadPermission() async {
final [hasLocationInUse, hasLocationAlways] = await Future.wait([
+69 -69
View File
@@ -4,95 +4,95 @@ import 'package:flutter/material.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:immich_ui/src/internal.dart';
class ImmichForm extends StatefulWidget {
final String? submitText;
final IconData? submitIcon;
final FutureOr<void> Function()? onSubmit;
final Widget child;
class ImmichFormController extends ChangeNotifier {
ImmichFormController({this.onSubmit});
const ImmichForm({
super.key,
this.submitText,
this.submitIcon,
required this.onSubmit,
required this.child,
});
FutureOr<void> Function()? onSubmit;
final formKey = GlobalKey<FormState>();
@override
State<ImmichForm> createState() => ImmichFormState();
static ImmichFormState of(BuildContext context) {
final scope = context.dependOnInheritedWidgetOfExactType<_ImmichFormScope>();
if (scope == null) {
throw FlutterError(
'ImmichForm.of() called with a context that does not contain an ImmichForm.\n'
'No ImmichForm ancestor could be found starting from the context that was passed to '
'ImmichForm.of(). This usually happens when the context provided is '
'from a widget above the ImmichForm.\n'
'The context used was:\n'
'$context',
);
}
return scope._formState;
}
}
class ImmichFormState extends State<ImmichForm> {
final _formKey = GlobalKey<FormState>();
bool _isLoading = false;
bool get isLoading => _isLoading;
FutureOr<void> submit() async {
final isValid = _formKey.currentState?.validate() ?? false;
if (!isValid) {
Future<void> submit() async {
if (_isLoading) {
return;
}
if (!(formKey.currentState?.validate() ?? false)) {
return;
}
setState(() {
_isLoading = true;
});
_isLoading = true;
notifyListeners();
try {
await widget.onSubmit?.call();
await onSubmit?.call();
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
_isLoading = false;
notifyListeners();
}
}
}
class ImmichForm extends StatefulWidget {
final FutureOr<void> Function()? onSubmit;
final Widget Function(BuildContext context, ImmichFormController form) builder;
final String? submitText;
final IconData? submitIcon;
const ImmichForm({
super.key,
this.onSubmit,
this.submitText,
this.submitIcon,
required this.builder,
});
@override
State<ImmichForm> createState() => _ImmichFormState();
}
class _ImmichFormState extends State<ImmichForm> {
late final ImmichFormController _controller;
@override
void initState() {
super.initState();
_controller = ImmichFormController(onSubmit: widget.onSubmit);
}
@override
void didUpdateWidget(ImmichForm oldWidget) {
super.didUpdateWidget(oldWidget);
_controller.onSubmit = widget.onSubmit;
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final submitText = widget.submitText ?? context.translations.submit;
return _ImmichFormScope(
formState: this,
child: Form(
key: _formKey,
child: Column(
spacing: ImmichSpacing.md,
children: [
widget.child,
ImmichTextButton(
return Form(
key: _controller.formKey,
child: Column(
spacing: ImmichSpacing.md,
children: [
widget.builder(context, _controller),
ListenableBuilder(
listenable: _controller,
builder: (context, _) => ImmichTextButton(
labelText: submitText,
icon: widget.submitIcon,
variant: ImmichVariant.filled,
loading: _isLoading,
onPressed: submit,
disabled: widget.onSubmit == null,
loading: _controller.isLoading,
onPressed: _controller.submit,
disabled: _controller.onSubmit == null,
),
],
),
),
],
),
);
}
}
class _ImmichFormScope extends InheritedWidget {
const _ImmichFormScope({required super.child, required ImmichFormState formState}) : _formState = formState;
final ImmichFormState _formState;
@override
bool updateShouldNotify(_ImmichFormScope oldWidget) => oldWidget._formState != _formState;
}
@@ -8,7 +8,7 @@ class ImmichPasswordInput extends StatefulWidget {
final TextEditingController? controller;
final FocusNode? focusNode;
final String? Function(String?)? validator;
final void Function(BuildContext, String)? onSubmit;
final void Function(String value)? onSubmit;
final TextInputAction? keyboardAction;
const ImmichPasswordInput({
@@ -7,7 +7,7 @@ class ImmichTextInput extends StatefulWidget {
final TextEditingController? controller;
final FocusNode? focusNode;
final String? Function(String?)? validator;
final void Function(BuildContext, String)? onSubmit;
final void Function(String value)? onSubmit;
final TextInputType keyboardType;
final TextInputAction? keyboardAction;
final List<String>? autofillHints;
@@ -29,7 +29,7 @@ class ImmichTextInput extends StatefulWidget {
this.hintText,
this.validator,
this.onSubmit,
this.keyboardType = TextInputType.text,
this.keyboardType = .text,
this.keyboardAction,
this.autofillHints,
this.suffixIcon,
@@ -49,7 +49,6 @@ class ImmichTextInput extends StatefulWidget {
class _ImmichTextInputState extends State<ImmichTextInput> {
late final FocusNode _focusNode;
String? _error;
@override
void initState() {
@@ -65,45 +64,20 @@ class _ImmichTextInputState extends State<ImmichTextInput> {
super.dispose();
}
String? _validateInput(String? value) {
final error = widget.validator?.call(value);
if (error != _error) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() => _error = error);
}
});
}
return null;
}
bool get _hasError => _error != null && _error!.isNotEmpty;
@override
Widget build(BuildContext context) {
final themeData = Theme.of(context);
return TextFormField(
controller: widget.controller,
focusNode: _focusNode,
enabled: widget.enabled,
autofocus: widget.autofocus,
autovalidateMode: widget.autovalidateMode,
decoration: InputDecoration(
hintText: widget.hintText,
labelText: widget.label,
labelStyle: themeData.inputDecorationTheme.labelStyle?.copyWith(
color: _hasError ? themeData.colorScheme.error : null,
),
errorText: _error,
suffixIcon: widget.suffixIcon,
),
decoration: InputDecoration(hintText: widget.hintText, labelText: widget.label, suffixIcon: widget.suffixIcon),
obscureText: widget.obscureText,
validator: _validateInput,
validator: widget.validator,
textInputAction: widget.keyboardAction,
onTap: () => setState(() => _error = null),
onTapOutside: (_) => _focusNode.unfocus(),
onFieldSubmitted: (value) => widget.onSubmit?.call(context, value),
onFieldSubmitted: (value) => widget.onSubmit?.call(value),
keyboardType: widget.keyboardType,
autofillHints: widget.autofillHints,
autocorrect: widget.autocorrect,
+15 -17
View File
@@ -15,23 +15,21 @@ class ImmichThemeProvider extends StatelessWidget {
brightness: colorScheme.brightness,
inputDecorationTheme: InputDecorationTheme(
floatingLabelBehavior: FloatingLabelBehavior.always,
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: colorScheme.primary),
borderRadius: const BorderRadius.all(Radius.circular(ImmichRadius.md)),
),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: colorScheme.primary),
borderRadius: const BorderRadius.all(Radius.circular(ImmichRadius.md)),
),
errorBorder: OutlineInputBorder(
borderSide: BorderSide(color: colorScheme.error),
borderRadius: const BorderRadius.all(Radius.circular(ImmichRadius.md)),
),
focusedErrorBorder: OutlineInputBorder(
borderSide: BorderSide(color: colorScheme.error),
borderRadius: const BorderRadius.all(Radius.circular(ImmichRadius.md)),
),
labelStyle: TextStyle(color: colorScheme.primary, fontWeight: FontWeight.w600),
border: WidgetStateInputBorder.resolveWith((states) {
final color = states.contains(WidgetState.error)
? colorScheme.error
: states.contains(WidgetState.focused)
? colorScheme.primary
: colorScheme.outline;
return OutlineInputBorder(
borderSide: BorderSide(color: color),
borderRadius: const BorderRadius.all(Radius.circular(ImmichRadius.md)),
);
}),
labelStyle: WidgetStateTextStyle.resolveWith((states) {
final color = states.contains(WidgetState.error) ? colorScheme.error : colorScheme.primary;
return TextStyle(color: color, fontWeight: FontWeight.w600);
}),
hintStyle: const TextStyle(fontSize: ImmichTextSize.body),
errorStyle: TextStyle(color: colorScheme.error, fontWeight: FontWeight.w600),
),
@@ -39,7 +39,7 @@ class _FormPageState extends State<FormPage> {
_result = 'Form submitted!';
});
},
child: Column(
builder: (context, form) => Column(
spacing: 10,
children: [
ImmichTextInput(
@@ -54,6 +54,7 @@ class _FormPageState extends State<FormPage> {
controller: _passwordController,
validator: (value) =>
value?.isEmpty ?? true ? 'Required' : null,
onSubmit: (_) => form.submit(),
),
],
),
@@ -11,6 +11,7 @@ import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/background_upload.service.dart';
@@ -42,6 +43,7 @@ void main() {
);
db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
await StoreService.init(storeRepository: DriftStoreRepository(db));
await MetadataRepository.ensureInitialized(db);
await Store.put(StoreKey.serverEndpoint, 'http://test-server.com');
await Store.put(StoreKey.deviceId, 'test-device-id');
@@ -13,6 +13,10 @@ void main() {
test('decode falls back to the default value when the raw input is unparseable', () {
for (final key in MetadataKey.values) {
// String keys can decode any string. So skip them
if (key.defaultValue is String) {
continue;
}
expect(
key.decode('not a valid encoding for any key'),
key.defaultValue,
+83 -83
View File
@@ -759,7 +759,7 @@ importers:
version: link:../packages/sdk
'@immich/ui':
specifier: ^0.77.0
version: 0.77.3(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)
version: 0.77.3(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.8)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.8)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.8)
'@mapbox/mapbox-gl-rtl-text':
specifier: 0.4.0
version: 0.4.0
@@ -795,7 +795,7 @@ importers:
version: 0.42.0
'@zoom-image/svelte':
specifier: ^0.3.0
version: 0.3.9(svelte@5.55.2)
version: 0.3.9(svelte@5.55.8)
dom-to-image:
specifier: ^2.6.0
version: 2.6.0
@@ -849,16 +849,16 @@ importers:
version: 5.2.2
svelte-i18n:
specifier: ^4.0.1
version: 4.0.1(svelte@5.55.2)
version: 4.0.1(svelte@5.55.8)
svelte-jsoneditor:
specifier: ^3.10.0
version: 3.12.0(svelte@5.55.2)
version: 3.12.0(svelte@5.55.8)
svelte-maplibre:
specifier: ^1.2.5
version: 1.3.0(svelte@5.55.2)
version: 1.3.0(svelte@5.55.8)
svelte-persisted-store:
specifier: ^0.12.0
version: 0.12.0(svelte@5.55.2)
version: 0.12.0(svelte@5.55.8)
tabbable:
specifier: ^6.2.0
version: 6.4.0
@@ -892,16 +892,16 @@ importers:
version: 3.1.2
'@sveltejs/adapter-static':
specifier: ^3.0.8
version: 3.0.10(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))
version: 3.0.10(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.8)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.8)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))
'@sveltejs/enhanced-img':
specifier: ^0.10.4
version: 0.10.4(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(rollup@4.55.1)(svelte@5.55.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 0.10.4(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.8)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(rollup@4.55.1)(svelte@5.55.8)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@sveltejs/kit':
specifier: ^2.56.1
version: 2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.8)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.8)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@sveltejs/vite-plugin-svelte':
specifier: 7.0.0
version: 7.0.0(svelte@5.55.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 7.0.0(svelte@5.55.8)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@tailwindcss/vite':
specifier: ^4.2.4
version: 4.2.4(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
@@ -910,13 +910,13 @@ importers:
version: 6.9.1
'@testing-library/svelte':
specifier: ^5.2.8
version: 5.3.1(svelte@5.55.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.5)
version: 5.3.1(svelte@5.55.8)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.5)
'@testing-library/user-event':
specifier: ^14.5.2
version: 14.6.1(@testing-library/dom@10.4.1)
'@trivago/prettier-plugin-sort-imports':
specifier: ^6.0.2
version: 6.0.2(prettier-plugin-svelte@3.5.1(prettier@3.8.3)(svelte@5.55.2))(prettier@3.8.3)(svelte@5.55.2)
version: 6.0.2(prettier-plugin-svelte@3.5.1(prettier@3.8.3)(svelte@5.55.8))(prettier@3.8.3)(svelte@5.55.8)
'@types/chromecast-caf-sender':
specifier: ^1.0.11
version: 1.0.11
@@ -955,7 +955,7 @@ importers:
version: 7.0.1(eslint@10.2.1(jiti@2.6.1))
eslint-plugin-svelte:
specifier: ^3.12.4
version: 3.17.1(eslint@10.2.1(jiti@2.6.1))(svelte@5.55.2)
version: 3.17.1(eslint@10.2.1(jiti@2.6.1))(svelte@5.55.8)
eslint-plugin-unicorn:
specifier: ^64.0.0
version: 64.0.0(eslint@10.2.1(jiti@2.6.1))
@@ -973,19 +973,19 @@ importers:
version: 4.2.0(prettier@3.8.3)
prettier-plugin-svelte:
specifier: ^3.3.3
version: 3.5.1(prettier@3.8.3)(svelte@5.55.2)
version: 3.5.1(prettier@3.8.3)(svelte@5.55.8)
rollup-plugin-visualizer:
specifier: ^7.0.0
version: 7.0.1(rolldown@1.0.0-rc.17)(rollup@4.55.1)
svelte:
specifier: 5.55.2
version: 5.55.2
specifier: 5.55.8
version: 5.55.8
svelte-check:
specifier: ^4.4.6
version: 4.4.6(picomatch@4.0.4)(svelte@5.55.2)(typescript@6.0.3)
version: 4.4.6(picomatch@4.0.4)(svelte@5.55.8)(typescript@6.0.3)
svelte-eslint-parser:
specifier: ^1.3.3
version: 1.6.0(svelte@5.55.2)
version: 1.6.0(svelte@5.55.8)
tailwindcss:
specifier: ^4.2.4
version: 4.2.4
@@ -7093,8 +7093,8 @@ packages:
engines: {node: '>= 4.0.0'}
hasBin: true
devalue@5.7.1:
resolution: {integrity: sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==}
devalue@5.8.1:
resolution: {integrity: sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==}
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
@@ -11606,8 +11606,8 @@ packages:
peerDependencies:
svelte: ^5.30.2
svelte@5.55.2:
resolution: {integrity: sha512-z41M/hi0ZPTzrwVKLvB/R1/Oo08gL1uIib8HZ+FncqxxtY9MLb01emg2fqk+WLZ/lNrrtNDFh7BZLDxAHvMgLw==}
svelte@5.55.8:
resolution: {integrity: sha512-4D6lyrMHmDaZalQOEBMCWCCidyZjSnec14/oPn0k627G6goxcck9xqMwz1tFLlQz+ZFvtTTHfFOlUayuAz0z6Q==}
engines: {node: '>=18'}
svg-parser@2.0.4:
@@ -15460,15 +15460,15 @@ snapshots:
pg-connection-string: 2.12.0
postgres: 3.4.9
'@immich/ui@0.77.3(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)':
'@immich/ui@0.77.3(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.8)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.8)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.8)':
dependencies:
'@internationalized/date': 3.12.1
'@mdi/js': 7.4.47
'@sveltejs/kit': 2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
bits-ui: 2.18.0(@internationalized/date@3.12.1)(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)
'@sveltejs/kit': 2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.8)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.8)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
bits-ui: 2.18.0(@internationalized/date@3.12.1)(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.8)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.8)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.8)
luxon: 3.7.2
simple-icons: 16.17.0
svelte: 5.55.2
svelte: 5.55.8
svelte-highlight: 7.9.0
tailwind-merge: 3.5.0
tailwind-variants: 3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.4)
@@ -16990,17 +16990,17 @@ snapshots:
dependencies:
acorn: 8.16.0
'@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))':
'@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.8)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.8)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))':
dependencies:
'@sveltejs/kit': 2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@sveltejs/kit': 2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.8)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.8)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@sveltejs/enhanced-img@0.10.4(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(rollup@4.55.1)(svelte@5.55.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
'@sveltejs/enhanced-img@0.10.4(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.8)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(rollup@4.55.1)(svelte@5.55.8)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
'@sveltejs/vite-plugin-svelte': 7.0.0(svelte@5.55.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@sveltejs/vite-plugin-svelte': 7.0.0(svelte@5.55.8)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
magic-string: 0.30.21
sharp: 0.34.5
svelte: 5.55.2
svelte-parse-markup: 0.1.5(svelte@5.55.2)
svelte: 5.55.8
svelte-parse-markup: 0.1.5(svelte@5.55.8)
vite: 8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-imagetools: 9.0.3(rollup@4.55.1)
zimmerframe: 1.1.4
@@ -17008,33 +17008,33 @@ snapshots:
- rollup
- supports-color
'@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
'@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.8)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.8)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
'@standard-schema/spec': 1.1.0
'@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0)
'@sveltejs/vite-plugin-svelte': 7.0.0(svelte@5.55.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@sveltejs/vite-plugin-svelte': 7.0.0(svelte@5.55.8)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@types/cookie': 0.6.0
acorn: 8.16.0
cookie: 0.6.0
devalue: 5.7.1
devalue: 5.8.1
esm-env: 1.2.2
kleur: 4.1.5
magic-string: 0.30.21
mrmime: 2.0.1
set-cookie-parser: 3.1.0
sirv: 3.0.2
svelte: 5.55.2
svelte: 5.55.8
vite: 8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
optionalDependencies:
'@opentelemetry/api': 1.9.1
typescript: 6.0.3
'@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
'@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.8)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
deepmerge: 4.3.1
magic-string: 0.30.21
obug: 2.1.1
svelte: 5.55.2
svelte: 5.55.8
vite: 8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitefu: 1.1.2(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
@@ -17288,15 +17288,15 @@ snapshots:
picocolors: 1.1.1
redent: 3.0.0
'@testing-library/svelte-core@1.0.0(svelte@5.55.2)':
'@testing-library/svelte-core@1.0.0(svelte@5.55.8)':
dependencies:
svelte: 5.55.2
svelte: 5.55.8
'@testing-library/svelte@5.3.1(svelte@5.55.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.5)':
'@testing-library/svelte@5.3.1(svelte@5.55.8)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.5)':
dependencies:
'@testing-library/dom': 10.4.1
'@testing-library/svelte-core': 1.0.0(svelte@5.55.2)
svelte: 5.55.2
'@testing-library/svelte-core': 1.0.0(svelte@5.55.8)
svelte: 5.55.8
optionalDependencies:
vite: 8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
@@ -17314,7 +17314,7 @@ snapshots:
'@tokenizer/token@0.3.0': {}
'@trivago/prettier-plugin-sort-imports@6.0.2(prettier-plugin-svelte@3.5.1(prettier@3.8.3)(svelte@5.55.2))(prettier@3.8.3)(svelte@5.55.2)':
'@trivago/prettier-plugin-sort-imports@6.0.2(prettier-plugin-svelte@3.5.1(prettier@3.8.3)(svelte@5.55.8))(prettier@3.8.3)(svelte@5.55.8)':
dependencies:
'@babel/generator': 7.29.1
'@babel/parser': 7.29.2
@@ -17326,8 +17326,8 @@ snapshots:
parse-imports-exports: 0.2.4
prettier: 3.8.3
optionalDependencies:
prettier-plugin-svelte: 3.5.1(prettier@3.8.3)(svelte@5.55.2)
svelte: 5.55.2
prettier-plugin-svelte: 3.5.1(prettier@3.8.3)(svelte@5.55.8)
svelte: 5.55.8
transitivePeerDependencies:
- supports-color
@@ -18223,10 +18223,10 @@ snapshots:
dependencies:
'@namnode/store': 0.1.0
'@zoom-image/svelte@0.3.9(svelte@5.55.2)':
'@zoom-image/svelte@0.3.9(svelte@5.55.8)':
dependencies:
'@zoom-image/core': 0.42.0
svelte: 5.55.2
svelte: 5.55.8
abbrev@1.1.1: {}
@@ -18591,15 +18591,15 @@ snapshots:
binary-extensions@2.3.0: {}
bits-ui@2.18.0(@internationalized/date@3.12.1)(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2):
bits-ui@2.18.0(@internationalized/date@3.12.1)(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.8)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.8)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.8):
dependencies:
'@floating-ui/core': 1.7.5
'@floating-ui/dom': 1.7.6
'@internationalized/date': 3.12.1
esm-env: 1.2.2
runed: 0.35.1(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)
svelte: 5.55.2
svelte-toolbelt: 0.10.6(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)
runed: 0.35.1(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.8)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.8)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.8)
svelte: 5.55.8
svelte-toolbelt: 0.10.6(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.8)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.8)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.8)
tabbable: 6.4.0
transitivePeerDependencies:
- '@sveltejs/kit'
@@ -19695,7 +19695,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
devalue@5.7.1: {}
devalue@5.8.1: {}
devlop@1.1.0:
dependencies:
@@ -20128,7 +20128,7 @@ snapshots:
'@types/eslint': 9.6.1
eslint-config-prettier: 10.1.8(eslint@10.2.1(jiti@2.6.1))
eslint-plugin-svelte@3.17.1(eslint@10.2.1(jiti@2.6.1))(svelte@5.55.2):
eslint-plugin-svelte@3.17.1(eslint@10.2.1(jiti@2.6.1))(svelte@5.55.8):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1))
'@jridgewell/sourcemap-codec': 1.5.5
@@ -20140,9 +20140,9 @@ snapshots:
postcss-load-config: 3.1.4(postcss@8.5.12)
postcss-safe-parser: 7.0.1(postcss@8.5.12)
semver: 7.7.4
svelte-eslint-parser: 1.6.0(svelte@5.55.2)
svelte-eslint-parser: 1.6.0(svelte@5.55.8)
optionalDependencies:
svelte: 5.55.2
svelte: 5.55.8
transitivePeerDependencies:
- ts-node
@@ -23856,10 +23856,10 @@ snapshots:
dependencies:
prettier: 3.8.3
prettier-plugin-svelte@3.5.1(prettier@3.8.3)(svelte@5.55.2):
prettier-plugin-svelte@3.5.1(prettier@3.8.3)(svelte@5.55.8):
dependencies:
prettier: 3.8.3
svelte: 5.55.2
svelte: 5.55.8
prettier@3.8.3: {}
@@ -24485,14 +24485,14 @@ snapshots:
dependencies:
queue-microtask: 1.2.3
runed@0.35.1(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2):
runed@0.35.1(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.8)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.8)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.8):
dependencies:
dequal: 2.0.3
esm-env: 1.2.2
lz-string: 1.5.0
svelte: 5.55.2
svelte: 5.55.8
optionalDependencies:
'@sveltejs/kit': 2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@sveltejs/kit': 2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.8)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.8)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
rw@1.3.3: {}
@@ -25109,23 +25109,23 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
svelte-awesome@3.3.5(svelte@5.55.2):
svelte-awesome@3.3.5(svelte@5.55.8):
dependencies:
svelte: 5.55.2
svelte: 5.55.8
svelte-check@4.4.6(picomatch@4.0.4)(svelte@5.55.2)(typescript@6.0.3):
svelte-check@4.4.6(picomatch@4.0.4)(svelte@5.55.8)(typescript@6.0.3):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
chokidar: 4.0.3
fdir: 6.5.0(picomatch@4.0.4)
picocolors: 1.1.1
sade: 1.8.1
svelte: 5.55.2
svelte: 5.55.8
typescript: 6.0.3
transitivePeerDependencies:
- picomatch
svelte-eslint-parser@1.6.0(svelte@5.55.2):
svelte-eslint-parser@1.6.0(svelte@5.55.8):
dependencies:
eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1
@@ -25135,7 +25135,7 @@ snapshots:
postcss-selector-parser: 7.1.1
semver: 7.7.4
optionalDependencies:
svelte: 5.55.2
svelte: 5.55.8
svelte-floating-ui@1.5.8:
dependencies:
@@ -25148,7 +25148,7 @@ snapshots:
dependencies:
highlight.js: 11.11.1
svelte-i18n@4.0.1(svelte@5.55.2):
svelte-i18n@4.0.1(svelte@5.55.8):
dependencies:
cli-color: 2.0.4
deepmerge: 4.3.1
@@ -25156,10 +25156,10 @@ snapshots:
estree-walker: 2.0.2
intl-messageformat: 10.7.18
sade: 1.8.1
svelte: 5.55.2
svelte: 5.55.8
tiny-glob: 0.2.9
svelte-jsoneditor@3.12.0(svelte@5.55.2):
svelte-jsoneditor@3.12.0(svelte@5.55.8):
dependencies:
'@codemirror/autocomplete': 6.20.1
'@codemirror/commands': 6.10.3
@@ -25186,42 +25186,42 @@ snapshots:
memoize-one: 6.0.0
natural-compare-lite: 1.4.0
sass: 1.99.0
svelte: 5.55.2
svelte-awesome: 3.3.5(svelte@5.55.2)
svelte: 5.55.8
svelte-awesome: 3.3.5(svelte@5.55.8)
svelte-select: 5.8.3
vanilla-picker: 2.12.3
svelte-maplibre@1.3.0(svelte@5.55.2):
svelte-maplibre@1.3.0(svelte@5.55.8):
dependencies:
d3-geo: 3.1.1
dequal: 2.0.3
just-compare: 2.3.0
maplibre-gl: 5.24.0
pmtiles: 3.2.1
svelte: 5.55.2
svelte: 5.55.8
svelte-parse-markup@0.1.5(svelte@5.55.2):
svelte-parse-markup@0.1.5(svelte@5.55.8):
dependencies:
svelte: 5.55.2
svelte: 5.55.8
svelte-persisted-store@0.12.0(svelte@5.55.2):
svelte-persisted-store@0.12.0(svelte@5.55.8):
dependencies:
svelte: 5.55.2
svelte: 5.55.8
svelte-select@5.8.3:
dependencies:
svelte-floating-ui: 1.5.8
svelte-toolbelt@0.10.6(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2):
svelte-toolbelt@0.10.6(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.8)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.8)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.8):
dependencies:
clsx: 2.1.1
runed: 0.35.1(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)
runed: 0.35.1(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.8)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.8)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.8)
style-to-object: 1.0.14
svelte: 5.55.2
svelte: 5.55.8
transitivePeerDependencies:
- '@sveltejs/kit'
svelte@5.55.2:
svelte@5.55.8:
dependencies:
'@jridgewell/remapping': 2.3.5
'@jridgewell/sourcemap-codec': 1.5.5
@@ -25232,7 +25232,7 @@ snapshots:
aria-query: 5.3.1
axobject-query: 4.1.0
clsx: 2.1.1
devalue: 5.7.1
devalue: 5.8.1
esm-env: 1.2.2
esrap: 2.2.4
is-reference: 3.0.3
+1 -1
View File
@@ -103,7 +103,7 @@
"prettier-plugin-sort-json": "^4.1.1",
"prettier-plugin-svelte": "^3.3.3",
"rollup-plugin-visualizer": "^7.0.0",
"svelte": "5.55.2",
"svelte": "5.55.8",
"svelte-check": "^4.4.6",
"svelte-eslint-parser": "^1.3.3",
"tailwindcss": "^4.2.4",
+1 -1
View File
@@ -12,7 +12,7 @@
const { trigger, selectedKey, onClose }: Props = $props();
</script>
<BasicModal title={$t('add_step')} {onClose}>
<BasicModal title={$t('add_step')} {onClose} size="medium">
{#await searchPluginMethods({ trigger })}
<div class="flex w-full place-content-center place-items-center">
<LoadingSpinner />
@@ -38,7 +38,7 @@
</script>
{#if method}
<FormModal title={$t('add_step')} {onClose} {onSubmit} disabled={!method} size="small">
<FormModal title={$t('add_step')} {onClose} {onSubmit} disabled={!method} size="medium">
<div class="flex items-center justify-between gap-2">
<div class="grow text-start">
<Text fontWeight="medium">{method.title}</Text>
@@ -35,7 +35,7 @@
</script>
{#if method}
<FormModal title={$t('step_details')} {onClose} {onSubmit} disabled={!method} size="small">
<FormModal title={$t('step_details')} {onClose} {onSubmit} disabled={!method} size="medium">
<div class="flex items-center justify-between gap-2">
<div class="grow text-start">
<Text fontWeight="medium">{method.title}</Text>
@@ -0,0 +1,35 @@
<script lang="ts">
import { workflowTemplates, type WorkflowTemplate } from '$lib/templates/workflow-templates';
import { FormModal, Icon, ListButton, Text } from '@immich/ui';
import { t } from 'svelte-i18n';
type Props = {
onClose: (template?: WorkflowTemplate) => void;
};
const { onClose }: Props = $props();
let selected = $state<WorkflowTemplate>();
const onSubmit = () => onClose(selected);
</script>
<FormModal title={$t('workflow_templates')} {onClose} {onSubmit} disabled={!selected} size="medium">
<div class="flex flex-col gap-2">
{#each workflowTemplates as template (template.id)}
<ListButton selected={selected?.id === template.id} onclick={() => (selected = template)}>
<div class="flex w-full items-center gap-3 text-start">
<div
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-immich-primary/10 text-immich-primary dark:bg-immich-dark-primary/15 dark:text-immich-dark-primary"
>
<Icon icon={template.icon} size="18" />
</div>
<div class="min-w-0 grow">
<Text fontWeight="medium">{template.name}</Text>
<Text size="tiny" color="muted">{template.description}</Text>
</div>
</div>
</ListButton>
{/each}
</div>
</FormModal>
@@ -17,7 +17,7 @@
const onSubmit = () => onClose(selected);
</script>
<FormModal title={$t('trigger')} {onClose} {onSubmit} size="small">
<FormModal title={$t('trigger')} {onClose} {onSubmit} size="medium">
<div class="flex flex-col gap-2">
{#each pluginManager.triggers as item (item.trigger)}
<ListButton selected={selected === item.trigger} onclick={() => (selected = item.trigger)}>
+21 -2
View File
@@ -10,10 +10,11 @@ import {
type WorkflowUpdateDto,
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { mdiCodeJson, mdiDelete, mdiPause, mdiPencil, mdiPlay, mdiPlus } from '@mdi/js';
import { mdiCodeJson, mdiDelete, mdiFileDocumentMultipleOutline, mdiPause, mdiPencil, mdiPlay, mdiPlus } from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
import { goto } from '$app/navigation';
import { eventManager } from '$lib/managers/event-manager.svelte';
import WorkflowTemplatePicker from '$lib/modals/WorkflowTemplatePicker.svelte';
import { Route } from '$lib/route';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
@@ -33,7 +34,25 @@ export const getWorkflowsActions = ($t: MessageFormatter) => {
}),
};
return { Create };
const UseTemplate: ActionItem = {
title: $t('use_template'),
icon: mdiFileDocumentMultipleOutline,
onAction: async () => {
const template = await modalManager.show(WorkflowTemplatePicker, {});
if (!template) {
return;
}
await handleCreateWorkflow({
trigger: template.trigger,
steps: template.steps,
name: template.name,
description: template.description,
enabled: false,
});
},
};
return { Create, UseTemplate };
};
export const getWorkflowActions = ($t: MessageFormatter, workflow: WorkflowResponseDto) => {
@@ -0,0 +1,56 @@
import { WorkflowTrigger, type WorkflowStepDto } from '@immich/sdk';
import { mdiAccountGroupOutline, mdiMonitorScreenshot } from '@mdi/js';
export type WorkflowTemplate = {
id: string;
name: string;
description: string;
icon: string;
trigger: WorkflowTrigger;
steps: WorkflowStepDto[];
};
export const workflowTemplates: WorkflowTemplate[] = [
{
id: '1',
name: 'Archive screenshots to album',
description: 'Add uploads with "screenshot" in the filename to an album and archive them',
icon: mdiMonitorScreenshot,
trigger: WorkflowTrigger.AssetCreate,
steps: [
{
method: 'immich-plugin-core#assetFileFilter',
config: {
pattern: 'screenshot',
matchType: 'contains',
caseSensitive: false,
},
},
{
method: 'immich-plugin-core#assetAddToAlbums',
config: { albumIds: [] },
},
{
method: 'immich-plugin-core#assetArchive',
config: { inverse: false },
},
],
},
{
id: '2',
name: 'Add person to album',
description: 'Add assets to an album when a specific person is recognized',
icon: mdiAccountGroupOutline,
trigger: WorkflowTrigger.PersonRecognized,
steps: [
{
method: 'immich-plugin-core#filterPerson',
config: { personIds: [], matchAny: true },
},
{
method: 'immich-plugin-core#assetAddToAlbums',
config: { albumIds: [] },
},
],
},
];
+301
View File
@@ -0,0 +1,301 @@
import type { AssetResponseDto } from '@immich/sdk';
import {
mdiBrightness6,
mdiCalendar,
mdiCamera,
mdiCameraIris,
mdiCameraOutline,
mdiClockEditOutline,
mdiCrosshairsGps,
mdiEarth,
mdiFileClockOutline,
mdiFileEditOutline,
mdiFileImageOutline,
mdiFitToScreen,
mdiFolderOutline,
mdiMapMarkerOutline,
mdiPanorama,
mdiPhoneRotateLandscape,
mdiRayStartArrow,
mdiStarOutline,
mdiTextBox,
mdiTimerOutline,
mdiWeightKilogram,
} from '@mdi/js';
import { DateTime } from 'luxon';
import type { MessageFormatter } from 'svelte-i18n';
import { getAssetResolution, getFileSize } from '$lib/utils/asset-utils';
import { fromISODateTime, fromISODateTimeUTC } from '$lib/utils/timeline-util';
const truncateMiddle = (path: string, maxLength = 50): string => {
if (path.length <= maxLength) {
return path;
}
const lastSlash = path.lastIndexOf('/');
const tail = lastSlash === -1 ? path : path.slice(lastSlash);
if (tail.length >= maxLength - 3) {
const half = Math.floor((maxLength - 3) / 2);
return path.slice(0, half) + '...' + path.slice(-half);
}
const headLength = maxLength - 3 - tail.length;
return path.slice(0, headLength) + '...' + tail;
};
const formatISODateToLocale = (iso: string, locale: string | undefined): string =>
fromISODateTimeUTC(iso).toLocaleString({ month: 'short', day: 'numeric', year: 'numeric' }, { locale });
const getDateTime = (asset: AssetResponseDto) => {
const timeZone = asset.exifInfo?.timeZone;
return timeZone && asset.exifInfo?.dateTimeOriginal
? fromISODateTime(asset.exifInfo.dateTimeOriginal, timeZone)
: fromISODateTimeUTC(asset.localDateTime);
};
type MetadataFieldDefinition = {
icon: string;
titleKey: string;
keys: readonly string[];
render: (asset: AssetResponseDto, $t: MessageFormatter, locale: string | undefined) => string;
};
const metadataFields = [
{
icon: mdiFileImageOutline,
titleKey: 'file_name_text',
keys: ['originalFileName'],
render: (asset, $t) => asset.originalFileName || $t('unknown'),
},
{
icon: mdiFolderOutline,
titleKey: 'path',
keys: ['originalPath'],
render: (asset, $t) => truncateMiddle(asset.originalPath) || $t('unknown'),
},
{
icon: mdiWeightKilogram,
titleKey: 'file_size',
keys: ['fileSize'],
render: (asset) => getFileSize(asset),
},
{
icon: mdiFitToScreen,
titleKey: 'resolution',
keys: ['resolution'],
render: (asset, $t) => getAssetResolution(asset) || $t('unknown'),
},
{
icon: mdiFileClockOutline,
titleKey: 'created_at',
keys: ['fileCreatedAt'],
render: (asset, _t, locale) => formatISODateToLocale(asset.fileCreatedAt, locale),
},
{
icon: mdiFileEditOutline,
titleKey: 'updated_at',
keys: ['fileModifiedAt'],
render: (asset, _t, locale) => formatISODateToLocale(asset.fileModifiedAt, locale),
},
{
icon: mdiCalendar,
titleKey: 'date_time_original',
keys: ['dateTimeOriginal'],
render: (asset, $t, locale) => {
const dateTime = getDateTime(asset);
return dateTime
? dateTime.toLocaleString(
{
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
timeZoneName: 'shortOffset',
},
{ locale },
)
: $t('unknown');
},
},
{
icon: mdiEarth,
titleKey: 'timezone',
keys: ['timeZone'],
render: (asset, $t) => getDateTime(asset)?.offsetNameShort ?? $t('unknown'),
},
{
icon: mdiClockEditOutline,
titleKey: 'modify_date',
keys: ['modifyDate'],
render: (asset, $t, locale) =>
asset.exifInfo?.modifyDate ? formatISODateToLocale(asset.exifInfo.modifyDate, locale) : $t('unknown'),
},
{
icon: mdiMapMarkerOutline,
titleKey: 'location',
keys: ['city', 'state', 'country'],
render: (asset, $t) => {
const parts = [asset.exifInfo?.city, asset.exifInfo?.state, asset.exifInfo?.country].filter(Boolean);
return parts.length > 0 ? parts.join(', ') : $t('unknown');
},
},
{
icon: mdiCrosshairsGps,
titleKey: 'gps',
keys: ['latitude', 'longitude'],
render: (asset, $t) =>
asset.exifInfo?.latitude != null && asset.exifInfo?.longitude != null
? `${asset.exifInfo.latitude.toFixed(4)}, ${asset.exifInfo.longitude.toFixed(4)}`
: $t('unknown'),
},
{
icon: mdiCameraOutline,
titleKey: 'make',
keys: ['make'],
render: (asset, $t) => asset.exifInfo?.make || $t('unknown'),
},
{
icon: mdiCamera,
titleKey: 'model',
keys: ['model'],
render: (asset, $t) => asset.exifInfo?.model || $t('unknown'),
},
{
icon: mdiCameraIris,
titleKey: 'lens_model',
keys: ['lensModel'],
render: (asset, $t) => asset.exifInfo?.lensModel || $t('unknown'),
},
{
icon: mdiCameraIris,
titleKey: 'f_number',
keys: ['fNumber'],
render: (asset, $t) => (asset.exifInfo?.fNumber == null ? $t('unknown') : `f/${asset.exifInfo.fNumber.toFixed(1)}`),
},
{
icon: mdiRayStartArrow,
titleKey: 'focal_length',
keys: ['focalLength'],
render: (asset, $t) => (asset.exifInfo?.focalLength == null ? $t('unknown') : `${asset.exifInfo.focalLength} mm`),
},
{
icon: mdiBrightness6,
titleKey: 'iso',
keys: ['iso'],
render: (asset, $t) => (asset.exifInfo?.iso == null ? $t('unknown') : `ISO ${asset.exifInfo.iso}`),
},
{
icon: mdiTimerOutline,
titleKey: 'exposure_time',
keys: ['exposureTime'],
render: (asset, $t) => asset.exifInfo?.exposureTime || $t('unknown'),
},
{
icon: mdiTextBox,
titleKey: 'description',
keys: ['description'],
render: (asset, $t) => asset.exifInfo?.description || $t('unknown'),
},
{
icon: mdiStarOutline,
titleKey: 'rating',
keys: ['rating'],
render: (asset, $t) => (asset.exifInfo?.rating == null ? $t('unknown') : `${asset.exifInfo.rating} stars`),
},
{
icon: mdiPhoneRotateLandscape,
titleKey: 'orientation',
keys: ['orientation'],
render: (asset, $t) => String(asset.exifInfo?.orientation || $t('unknown')),
},
{
icon: mdiPanorama,
titleKey: 'projection_type',
keys: ['projectionType'],
render: (asset, $t) => asset.exifInfo?.projectionType || $t('unknown'),
},
] as const satisfies readonly MetadataFieldDefinition[];
export type MetadataFieldKey = (typeof metadataFields)[number]['keys'][number];
export type DifferingMetadataFields = Partial<Record<MetadataFieldKey, boolean>>;
export const metadataKeys: readonly MetadataFieldKey[] = metadataFields.flatMap(({ keys }) => keys);
export const countDifferingMetadataItems = (differing: DifferingMetadataFields): number =>
metadataFields.filter(({ keys }) => keys.some((k) => differing[k as MetadataFieldKey])).length;
export const getAllMetadataItems = (asset: AssetResponseDto, $t: MessageFormatter, locale: string | undefined) =>
metadataFields.map(({ icon, titleKey, keys, render }) => ({
icon,
title: $t(titleKey),
render: render(asset, $t, locale),
keys,
}));
const normalizeForComparison = (key: MetadataFieldKey, value: unknown): unknown => {
if (value === null || value === undefined) {
return value;
}
if (key === 'fileCreatedAt' || key === 'fileModifiedAt' || key === 'dateTimeOriginal' || key === 'modifyDate') {
const dateTime = DateTime.fromISO(String(value));
return dateTime.isValid ? dateTime.toISO() : String(value);
}
if (key === 'fNumber' && typeof value === 'number') {
return Number(value.toFixed(1));
}
if ((key === 'latitude' || key === 'longitude') && typeof value === 'number') {
return Number(value.toFixed(4));
}
if (key === 'focalLength' && typeof value === 'number') {
return Number(value.toFixed(2));
}
return value;
};
const getValueForAsset = (asset: AssetResponseDto, key: MetadataFieldKey): unknown => {
switch (key) {
case 'fileCreatedAt':
case 'fileModifiedAt':
case 'originalFileName':
case 'originalPath': {
return asset[key];
}
case 'fileSize': {
return getFileSize(asset);
}
case 'resolution': {
return getAssetResolution(asset);
}
default: {
if (asset.exifInfo && key in asset.exifInfo) {
return asset.exifInfo[key as keyof typeof asset.exifInfo];
}
return undefined;
}
}
};
export const computeDifferingMetadataFields = (assets: AssetResponseDto[]): DifferingMetadataFields => {
const diffs: DifferingMetadataFields = {};
for (const key of metadataKeys) {
const uniqueValues = new Set<unknown>();
for (const asset of assets) {
const value = getValueForAsset(asset, key);
if (value !== undefined && value !== null) {
uniqueValues.add(normalizeForComparison(key, value));
}
}
diffs[key] = uniqueValues.size > 1;
}
return diffs;
};
@@ -1,103 +1,42 @@
<script lang="ts">
import { locale } from '$lib/stores/preferences.store';
import { getAssetMediaUrl } from '$lib/utils';
import { getAssetResolution, getFileSize } from '$lib/utils/asset-utils';
import { getAllMetadataItems, type DifferingMetadataFields } from '$lib/utils/duplicate-utils';
import { getAltText } from '$lib/utils/thumbnail-util';
import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
import { type AssetResponseDto, getAllAlbums } from '@immich/sdk';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { getAllAlbums, type AssetResponseDto } from '@immich/sdk';
import { Icon } from '@immich/ui';
import {
mdiBookmarkOutline,
mdiCalendar,
mdiClock,
mdiFile,
mdiFitToScreen,
mdiFolderOutline,
mdiHeart,
mdiImageMultipleOutline,
mdiImageOutline,
mdiMagnifyPlus,
mdiMapMarkerOutline,
} from '@mdi/js';
import { mdiBookmarkOutline, mdiHeart, mdiImageMultipleOutline, mdiMagnifyPlus } from '@mdi/js';
import { t } from 'svelte-i18n';
import InfoRow from './InfoRow.svelte';
interface Props {
assets: AssetResponseDto[];
asset: AssetResponseDto;
isSelected: boolean;
onSelectAsset: (asset: AssetResponseDto) => void;
onViewAsset: (asset: AssetResponseDto) => void;
differingMetadataFields: DifferingMetadataFields;
showMore?: boolean;
initialVisibleCount?: number;
}
let { assets, asset, isSelected, onSelectAsset, onViewAsset }: Props = $props();
let {
asset,
isSelected,
onSelectAsset,
onViewAsset,
differingMetadataFields,
showMore = false,
initialVisibleCount = 5,
}: Props = $props();
let isFromExternalLibrary = $derived(!!asset.libraryId);
let locationParts = $derived([asset.exifInfo?.city, asset.exifInfo?.state, asset.exifInfo?.country].filter(Boolean));
let timeZone = $derived(asset.exifInfo?.timeZone);
let dateTime = $derived(
timeZone && asset.exifInfo?.dateTimeOriginal
? fromISODateTime(asset.exifInfo.dateTimeOriginal, timeZone)
: fromISODateTimeUTC(asset.localDateTime),
const visibleMetadataItems = $derived(
getAllMetadataItems(asset, $t, $locale)
.filter(({ keys }) => keys.some((k) => differingMetadataFields[k]))
.slice(0, showMore ? undefined : initialVisibleCount),
);
const isDifferent = (getter: (asset: AssetResponseDto) => string | undefined): boolean => {
return new Set(assets.map((asset) => getter(asset))).size > 1;
};
const hasDifferentValues = $derived({
fileName: isDifferent((a) => a.originalFileName),
fileSize: isDifferent((a) => getFileSize(a)),
resolution: isDifferent((a) => getAssetResolution(a)),
originalPath: isDifferent((a) => a.originalPath ?? $t('unknown')),
date: isDifferent((a) => {
const tz = a.exifInfo?.timeZone;
const dt =
tz && a.exifInfo?.dateTimeOriginal
? fromISODateTime(a.exifInfo.dateTimeOriginal, tz)
: fromISODateTimeUTC(a.localDateTime);
return dt?.toLocaleString({ month: 'short', day: 'numeric', year: 'numeric' }, { locale: $locale });
}),
time: isDifferent((a) => {
const tz = a.exifInfo?.timeZone;
const dt =
tz && a.exifInfo?.dateTimeOriginal
? fromISODateTime(a.exifInfo.dateTimeOriginal, tz)
: fromISODateTimeUTC(a.localDateTime);
return dt?.toLocaleString(
{
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
timeZoneName: tz ? 'shortOffset' : undefined,
},
{ locale: $locale },
);
}),
location: isDifferent(
(a) => [a.exifInfo?.city, a.exifInfo?.state, a.exifInfo?.country].filter(Boolean).join(', ') || 'unknown',
),
});
const getBasePath = (fullpath: string, fileName: string): string => {
if (fileName && fullpath.endsWith(fileName)) {
return fullpath.slice(0, -(fileName.length + 1));
}
return fullpath;
};
function truncateMiddle(path: string, maxLength: number = 50): string {
if (path.length <= maxLength) {
return path;
}
const start = Math.floor(maxLength / 2) - 2;
const end = Math.floor(maxLength / 2) - 2;
return path.slice(0, Math.max(0, start)) + '...' + path.slice(Math.max(0, path.length - end));
}
</script>
<div class="min-w-60 flex-1 rounded-lg border transition-colors">
@@ -166,69 +105,13 @@
? 'bg-success/15 dark:bg-[#001a06]'
: 'bg-transparent'}"
>
<InfoRow
icon={mdiImageOutline}
highlight={hasDifferentValues.fileName}
title={$t('file_name_with_value', { values: { file_name: asset.originalFileName ?? '' } })}
>
{asset.originalFileName}
</InfoRow>
<InfoRow
icon={mdiFolderOutline}
highlight={hasDifferentValues.originalPath}
title={$t('full_path', { values: { path: asset.originalPath } })}
>
{truncateMiddle(getBasePath(asset.originalPath, asset.originalFileName)) || $t('unknown')}
</InfoRow>
<InfoRow icon={mdiFile} highlight={hasDifferentValues.fileSize} title={$t('file_size')}>
{getFileSize(asset)}
</InfoRow>
<InfoRow icon={mdiFitToScreen} highlight={hasDifferentValues.resolution} title={$t('resolution')}>
{getAssetResolution(asset)}
</InfoRow>
<InfoRow icon={mdiCalendar} highlight={hasDifferentValues.date} title={$t('date')}>
{#if dateTime}
{dateTime.toLocaleString(
{
month: 'short',
day: 'numeric',
year: 'numeric',
},
{ locale: $locale },
)}
{:else}
{$t('unknown')}
{/if}
</InfoRow>
<InfoRow icon={mdiClock} highlight={hasDifferentValues.time} title={$t('time')}>
{#if dateTime}
{dateTime.toLocaleString(
{
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
timeZoneName: timeZone ? 'shortOffset' : undefined,
},
{ locale: $locale },
)}
{:else}
{$t('unknown')}
{/if}
</InfoRow>
<InfoRow icon={mdiMapMarkerOutline} highlight={hasDifferentValues.location} title={$t('location')}>
{#if locationParts.length > 0}
{locationParts.join(', ')}
{:else}
{$t('unknown')}
{/if}
</InfoRow>
{#each visibleMetadataItems as { icon, title, render, keys } (keys[0])}
<InfoRow {icon} {title}>
{render}
</InfoRow>
{/each}
<!-- Albums always shown -->
<InfoRow icon={mdiBookmarkOutline} borderBottom={false} title={$t('albums')}>
{#await getAllAlbums({ assetId: asset.id })}
{$t('scanning_for_album')}
@@ -6,10 +6,15 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import { handlePromiseError } from '$lib/utils';
import { getNextAsset, getPreviousAsset } from '$lib/utils/asset-utils';
import {
computeDifferingMetadataFields,
countDifferingMetadataItems,
type DifferingMetadataFields,
} from '$lib/utils/duplicate-utils';
import { navigate } from '$lib/utils/navigation';
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
import { Button } from '@immich/ui';
import { mdiCheck, mdiImageMultipleOutline, mdiTrashCanOutline } from '@mdi/js';
import { Button, Icon } from '@immich/ui';
import { mdiCheck, mdiChevronDown, mdiChevronUp, mdiImageMultipleOutline, mdiTrashCanOutline } from '@mdi/js';
import { onDestroy, onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity';
@@ -26,6 +31,13 @@
let selectedAssetIds = $state(new SvelteSet<string>());
let trashCount = $derived(assets.length - selectedAssetIds.size);
const InitialVisibleCount = 5;
const differingMetadataFields: DifferingMetadataFields = $derived(computeDifferingMetadataFields(assets));
const differingCount = $derived(countDifferingMetadataItems(differingMetadataFields));
const hasMore = $derived(differingCount > InitialVisibleCount);
let showMore = $state(false);
onMount(() => {
if (suggestedKeepAssetIds.length > 0) {
for (const id of suggestedKeepAssetIds) {
@@ -160,10 +172,29 @@
<div class="overflow-x-auto p-2">
<div class="mx-auto flex w-fit min-w-full flex-nowrap place-items-start justify-center gap-1">
{#each assets as asset (asset.id)}
<DuplicateAsset {assets} {asset} {onSelectAsset} isSelected={selectedAssetIds.has(asset.id)} {onViewAsset} />
<DuplicateAsset
{asset}
{onSelectAsset}
isSelected={selectedAssetIds.has(asset.id)}
{onViewAsset}
{differingMetadataFields}
{showMore}
initialVisibleCount={InitialVisibleCount}
/>
{/each}
</div>
</div>
{#if hasMore}
<div class="flex justify-center pb-2">
<Button size="small" variant="ghost" color="secondary" onclick={() => (showMore = !showMore)}>
<Icon icon={showMore ? mdiChevronUp : mdiChevronDown} size="18" class="me-1" />
{showMore
? $t('show_less')
: $t('show_more_fields', { values: { count: differingCount - InitialVisibleCount } })}
</Button>
</div>
{/if}
</div>
{#if assetViewerManager.isViewing}
@@ -6,21 +6,23 @@
icon: string;
children?: Snippet;
borderBottom?: boolean;
highlight?: boolean;
title?: string;
}
let { icon, children, borderBottom = true, highlight = false, title }: Props = $props();
let { icon, children, borderBottom = true, title }: Props = $props();
</script>
<div class="grid w-full grid-cols-[25px_1fr] px-1 py-0.5" class:border-b={borderBottom} {title}>
<Icon {icon} size="18" class="text-dark/25 {highlight ? 'text-primary/75' : ''}" />
<div class="w-full justify-self-end overflow-hidden rounded-sm px-1 text-end transition-colors">
<Text
size="tiny"
fontWeight={highlight ? 'semi-bold' : 'normal'}
class={`${highlight ? 'text-primary' : ''} w-full overflow-hidden text-ellipsis`}
>
<div class="grid w-full grid-cols-[20px_auto_1fr] overflow-hidden px-1 py-0.5" class:border-b={borderBottom} {title}>
<Icon {icon} size="16" class="self-center text-dark/25" />
{#if title}
<Text size="tiny" class="self-center truncate px-1 pr-2 text-immich-fg/40 dark:text-immich-dark-fg/40">
{title}
</Text>
{/if}
<div class="justify-self-end overflow-hidden rounded-sm px-1 text-end transition-colors">
<Text size="tiny" class="break-all">
{@render children?.()}
</Text>
</div>
+53 -90
View File
@@ -4,26 +4,24 @@
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/EmptyPlaceholder.svelte';
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
import { Route } from '$lib/route';
import { getWorkflowActions, getWorkflowsActions, getWorkflowShowSchemaAction } from '$lib/services/workflow.service';
import { getWorkflowForShare, type WorkflowResponseDto } from '@immich/sdk';
import {
Badge,
Button,
Card,
CardBody,
CardDescription,
CardHeader,
CardTitle,
CodeBlock,
Container,
Icon,
IconButton,
MenuItemType,
menuManager,
Text,
VStack,
} from '@immich/ui';
import { mdiClose, mdiDotsVertical } from '@mdi/js';
import { mdiClose, mdiDotsVertical, mdiFlashOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity';
import type { PageData } from './$types';
@@ -46,20 +44,6 @@
}
};
const getTriggerLabel = (triggerType: string) => {
const labels: Record<string, string> = {
AssetCreate: $t('asset_created'),
PersonRecognized: $t('person_recognized'),
};
return labels[triggerType] || triggerType;
};
const formatTimestamp = (createdAt: string) =>
new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(createdAt));
const showWorkflowMenu = (event: MouseEvent, workflow: WorkflowResponseDto) => {
const { ToggleEnabled, Edit, Delete } = getWorkflowActions($t, workflow);
void menuManager.show({
@@ -75,7 +59,7 @@
});
};
const { Create } = $derived(getWorkflowsActions($t));
const { Create, UseTemplate } = $derived(getWorkflowsActions($t));
const onWorkflowCreate = async (response: WorkflowResponseDto) => {
await goto(Route.viewWorkflow(response));
@@ -92,13 +76,7 @@
<OnEvents {onWorkflowCreate} {onWorkflowUpdate} {onWorkflowDelete} />
{#snippet chipItem(title: string)}
<span class="rounded-xl border border-gray-200/80 bg-light px-3 py-1.5 text-sm dark:border-gray-600">
<span class="font-medium text-dark">{title}</span>
</span>
{/snippet}
<UserPageLayout title={data.meta.title} actions={[Create]} scrollbar={false}>
<UserPageLayout title={data.meta.title} actions={[UseTemplate, Create]} scrollbar={false}>
<section class="flex place-content-center sm:mx-4">
<Container center size="large" class="pb-28">
{#if workflows.length === 0}
@@ -111,92 +89,77 @@
class="mx-auto mt-10"
/>
{:else}
<div class="my-6 grid gap-6">
<div class="my-6 flex flex-col gap-3">
{#each workflows as workflow (workflow.id)}
<Card class="border border-light-200">
<CardHeader
class={`flex flex-row gap-4 px-8 py-6 sm:items-center sm:gap-6 ${
workflow.enabled
? 'bg-linear-to-r from-green-50 to-white dark:from-green-800/50 dark:to-green-950/45'
: 'bg-neutral-50 dark:bg-neutral-900'
}`}
>
<div class="flex-1">
<div class="flex items-center gap-3">
<span class="rounded-full {workflow.enabled ? 'size-3 bg-success' : 'size-3 rounded-full bg-muted'}"
></span>
<CardTitle>{workflow.name || $t('workflow')}</CardTitle>
<Card class="group shadow-none transition-colors hover:border-primary">
<CardHeader>
<a
href={Route.viewWorkflow({ id: workflow.id })}
class="flex items-center gap-4"
class:opacity-55={!workflow.enabled}
>
<div
class={`flex size-11 shrink-0 items-center justify-center rounded-xl ${
workflow.enabled
? 'bg-immich-primary/10 text-immich-primary dark:bg-immich-dark-primary/15 dark:text-immich-dark-primary'
: 'bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-500'
}`}
>
<Icon icon={mdiFlashOutline} size="20" />
</div>
{#if workflow.description}
<CardDescription class="mt-1 text-sm">{workflow.description}</CardDescription>
{/if}
</div>
<div class="flex items-center gap-4">
<div class="hidden text-right sm:block">
<Text size="tiny">{$t('created_at')}</Text>
<Text size="small" fontWeight="medium">
{formatTimestamp(workflow.createdAt)}
</Text>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<CardTitle class="truncate font-semibold text-dark group-hover:text-primary">
{workflow.name || $t('workflow')}
</CardTitle>
{#if !workflow.enabled}
<Badge size="small" color="secondary">
{$t('disabled')}
</Badge>
{/if}
</div>
{#if workflow.description}
<CardDescription class="mt-0.5 truncate">
{workflow.description}
</CardDescription>
{/if}
</div>
<IconButton
shape="round"
variant="ghost"
color="secondary"
icon={mdiDotsVertical}
aria-label={$t('menu')}
onclick={(event: MouseEvent) => showWorkflowMenu(event, workflow)}
onclick={(event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
showWorkflowMenu(event, workflow);
}}
/>
</div>
</CardHeader>
<CardBody class="space-y-6">
<div class="grid gap-4 md:grid-cols-3">
<!-- Trigger Section -->
<div class="rounded-2xl border border-light-200 bg-light-50 p-4">
<div class="mb-3">
<Text size="tiny" color="muted" fontWeight="medium">{$t('trigger')}</Text>
</div>
{@render chipItem(getTriggerLabel(workflow.trigger))}
</div>
<!-- Actions Section -->
<div class="rounded-2xl border border-light-200 bg-light-50 p-4">
<div class="mb-3">
<Text size="tiny" color="muted" fontWeight="medium">{$t('steps')}</Text>
</div>
<div>
{#if workflow.steps.length === 0}
<span class="text-sm text-light-600">
{$t('no_steps')}
</span>
{:else}
<div class="flex flex-wrap gap-2">
{#each workflow.steps as step, i (i)}
{@render chipItem(pluginManager.getMethodLabel(step.method))}
{/each}
</div>
{/if}
</div>
</div>
</div>
</a>
{#if expandedIds.has(workflow.id)}
{#await getWorkflowForShare({ id: workflow.id }) then result}
<VStack gap={2} class="w-full rounded-2xl border border-light-200 bg-light-50 p-4">
<div class="border-t border-gray-200 p-4 dark:border-gray-800">
<CodeBlock code={JSON.stringify(result, null, 2)} lineNumbers />
<Button
class="mt-2"
leadingIcon={mdiClose}
fullWidth
variant="ghost"
color="secondary"
onclick={() => toggleExpanded(workflow.id)}>{$t('close')}</Button
onclick={() => toggleExpanded(workflow.id)}
>
</VStack>
{$t('close')}
</Button>
</div>
{/await}
{/if}
</CardBody>
</CardHeader>
</Card>
{/each}
</div>
@@ -1,5 +1,5 @@
<script lang="ts">
import { goto, invalidate } from '$app/navigation';
import { beforeNavigate, goto, invalidate } from '$app/navigation';
import OnEvents from '$lib/components/OnEvents.svelte';
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
import WorkflowAddStepModal from '$lib/modals/WorkflowAddStepModal.svelte';
@@ -8,7 +8,7 @@
import { Route } from '$lib/route';
import { handleUpdateWorkflow } from '$lib/services/workflow.service';
import { getTriggerDescription, getTriggerName } from '$lib/utils/workflow';
import type { WorkflowResponseDto, WorkflowStepDto } from '@immich/sdk';
import type { WorkflowResponseDto, WorkflowStepDto, WorkflowUpdateDto } from '@immich/sdk';
import {
ActionBar,
AppShell,
@@ -29,26 +29,40 @@
IconButton,
Input,
modalManager,
Stack,
Switch,
Text,
Textarea,
VStack,
type ActionItem,
} from '@immich/ui';
import {
mdiArrowLeft,
mdiCodeJson,
mdiContentSave,
mdiFlashOutline,
mdiFormatListBulletedSquare,
mdiInformationOutline,
mdiPencilOutline,
mdiPlus,
mdiTrashCanOutline,
} from '@mdi/js';
import { cloneDeep, isEqual } from 'lodash-es';
import { flushSync } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
import WorkflowJsonEditor from './WorkflowJsonEditor.svelte';
import WorkflowStepCard from './WorkflowStepCard.svelte';
import WorkflowStepDragImage from './WorkflowStepDragImage.svelte';
import WorkflowSummary from './WorkflowSummary.svelte';
type WorkflowJsonContent = Required<
Pick<WorkflowUpdateDto, 'description' | 'enabled' | 'name' | 'steps' | 'trigger'>
>;
type EditMode = 'visual' | 'json';
type StepDragImage = {
description?: string;
isFilter: boolean;
label: string;
stepNumber: number;
};
type Props = {
data: PageData;
@@ -57,6 +71,27 @@
let { data }: Props = $props();
let { id, enabled, name, description, trigger, steps } = $derived(data.workflow);
let savedWorkflow = $state(cloneDeep(data.workflow));
let allowNavigation = $state(false);
let isShowingNavigationDialog = $state(false);
let isSaving = $state(false);
let editMode = $state<EditMode>('visual');
let draggedIndex = $state<number | null>(null);
let dragHandleHoverIndex = $state<number | null>(null);
let dragImageElement = $state<HTMLElement | null>(null);
let dragImage = $state<StepDragImage>({ isFilter: false, label: '', stepNumber: 1 });
let dropTargetIndex = $state<number | null>(null);
const workflowSummary = $derived({ name, description, trigger, steps });
const workflowJsonContent = $derived<WorkflowJsonContent>({ description, enabled, name, steps, trigger });
const hasChanges = $derived(
enabled !== savedWorkflow.enabled ||
name !== savedWorkflow.name ||
description !== savedWorkflow.description ||
!isEqual(trigger, savedWorkflow.trigger) ||
!isEqual(steps, savedWorkflow.steps),
);
const handleAddStep = async () => {
const step = await modalManager.show(WorkflowAddStepModal, { trigger });
@@ -65,13 +100,90 @@
}
};
const handleEditStep = async (step: WorkflowStepDto) => {
const result = await modalManager.show(WorkflowEditStepModal, { trigger, step });
if (result) {
Object.assign(step, result);
const handleInsertStep = async (index: number) => {
const step = await modalManager.show(WorkflowAddStepModal, { trigger });
if (step) {
steps = [...steps.slice(0, index), step, ...steps.slice(index)];
}
};
const replaceStep = (index: number, step: WorkflowStepDto) => {
steps = steps.map((current, i) => (i === index ? cloneDeep(step) : current));
};
const handleEditStep = async (index: number) => {
const step = steps[index];
if (!step) {
return;
}
const result = await modalManager.show(WorkflowEditStepModal, { trigger, step: cloneDeep(step) });
if (result) {
replaceStep(index, result);
}
};
const handleDragStart = (index: number, event: DragEvent) => {
draggedIndex = index;
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', String(index));
const step = steps[index];
const method = step ? pluginManager.getMethod(step.method) : undefined;
dragImage = {
description: method?.description,
isFilter: method?.uiHints?.includes('filter') ?? false,
label: step ? pluginManager.getMethodLabel(step.method) : '',
stepNumber: index + 1,
};
flushSync();
if (dragImageElement) {
event.dataTransfer.setDragImage(dragImageElement, 16, 22);
}
}
};
const handleDragOver = (index: number, event: DragEvent) => {
if (draggedIndex === null) {
return;
}
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move';
}
if (dropTargetIndex !== index) {
dropTargetIndex = index;
}
};
const handleDragLeave = (index: number) => {
if (dropTargetIndex === index) {
dropTargetIndex = null;
}
};
const handleDrop = (index: number, event: DragEvent) => {
event.preventDefault();
const from = draggedIndex;
draggedIndex = null;
dropTargetIndex = null;
if (from === null || from === index) {
return;
}
const next = [...steps];
const [moved] = next.splice(from, 1);
next.splice(index, 0, moved);
steps = next;
};
const handleDragEnd = () => {
draggedIndex = null;
dragHandleHoverIndex = null;
dropTargetIndex = null;
};
const handleDeleteStep = async (index: number) => {
const confirmed = await modalManager.showDialog({ title: $t('step_delete'), prompt: $t('step_delete_confirm') });
if (confirmed) {
@@ -80,11 +192,16 @@
}
};
const onClose = async () => {
// check for pending changes
await goto(Route.workflows());
const handleJsonContentChange = (content: WorkflowJsonContent) => {
enabled = content.enabled;
name = content.name;
description = content.description;
trigger = content.trigger;
steps = cloneDeep(content.steps);
};
const onClose = () => goto(Route.workflows());
const onChangeTrigger = async () => {
const newTrigger = await modalManager.show(WorkflowTriggerPicker, { selected: trigger });
if (newTrigger) {
@@ -95,163 +212,228 @@
const onWorkflowUpdate = async (response: WorkflowResponseDto) => {
if (id === response.id) {
data.workflow = response;
savedWorkflow = cloneDeep(response);
await invalidate('workflow:data');
}
};
const Done: ActionItem = {
title: $t('save'),
icon: mdiContentSave,
color: 'primary',
onAction: () => handleUpdateWorkflow(id, { enabled, name, description, trigger, steps }),
const confirmNavigation = async () => {
if (!hasChanges) {
return true;
}
if (isShowingNavigationDialog) {
return false;
}
try {
isShowingNavigationDialog = true;
return await modalManager.showDialog({
prompt: $t('workflow_navigation_prompt'),
confirmColor: 'primary',
});
} finally {
isShowingNavigationDialog = false;
}
};
const saveWorkflow = async () => {
if (!hasChanges || isSaving) {
return;
}
isSaving = true;
try {
const submitted = { enabled, name, description, trigger, steps: cloneDeep(steps) };
const saved = await handleUpdateWorkflow(id, submitted);
if (saved) {
Object.assign(savedWorkflow, submitted);
}
} finally {
isSaving = false;
}
};
beforeNavigate(({ cancel, to, willUnload }) => {
if (!hasChanges || allowNavigation) {
return;
}
cancel();
if (willUnload || !to) {
return;
}
void confirmNavigation().then((confirmed) => {
if (confirmed) {
allowNavigation = true;
void goto(to.url);
}
});
});
</script>
<OnEvents {onWorkflowUpdate} />
<AppShell>
<AppShell class="">
<AppShellBar>
<ActionBar static {onClose} translations={{ close: $t('back') }} closeIcon={mdiArrowLeft}>
<ControlBarHeader>
<ControlBarTitle>{data.workflow.name}</ControlBarTitle>
<ControlBarDescription>{data.workflow.description}</ControlBarDescription>
</ControlBarHeader>
<ControlBarContent class="flex justify-end">
<HeaderActionButton action={Done} variant="filled" />
<ControlBarContent class="flex items-center justify-end gap-6">
<div class="flex gap-1 rounded-full border border-light-200 bg-light p-1" role="group">
<Button
variant={editMode === 'visual' ? 'filled' : 'ghost'}
color={editMode === 'visual' ? 'primary' : 'secondary'}
size="small"
leadingIcon={mdiFormatListBulletedSquare}
aria-pressed={editMode === 'visual'}
onclick={() => (editMode = 'visual')}
shape="round"
>
{$t('visual')}
</Button>
<Button
variant={editMode === 'json' ? 'filled' : 'ghost'}
color={editMode === 'json' ? 'primary' : 'secondary'}
size="small"
leadingIcon={mdiCodeJson}
aria-pressed={editMode === 'json'}
onclick={() => (editMode = 'json')}
shape="round"
>
JSON
</Button>
</div>
<Button
variant="filled"
size="small"
color="primary"
leadingIcon={mdiContentSave}
disabled={!hasChanges || isSaving}
loading={isSaving}
onclick={saveWorkflow}
>
{$t('save')}
</Button>
</ControlBarContent>
</ActionBar>
</AppShellBar>
<Container size="medium" class="pt-8 pb-24" center>
<VStack gap={4}>
<Card expandable>
<CardHeader>
<div class="flex place-items-start gap-3">
<Icon icon={mdiInformationOutline} size="20" class="mt-1" />
<div class="flex flex-col">
<CardTitle>
{$t('workflow_info')}
</CardTitle>
{#if editMode === 'visual'}
<Card class="shadow-none" expandable>
<CardHeader>
<div class="flex place-items-start gap-3">
<Icon icon={mdiInformationOutline} size="20" class="mt-1" />
<div class="flex flex-col">
<CardTitle>
{$t('workflow_info')}
</CardTitle>
</div>
</div>
</div>
</CardHeader>
</CardHeader>
<CardBody>
<VStack gap={4}>
<div class="relative w-full overflow-hidden rounded-xl border p-4" class:bg-primary-50={enabled}>
<Field label={enabled ? $t('enabled') : $t('disabled')} color={enabled ? 'primary' : 'secondary'}>
<Switch bind:checked={enabled} />
<CardBody>
<VStack gap={4}>
<div class="relative w-full overflow-hidden rounded-xl border p-4" class:bg-primary-50={enabled}>
<Field label={enabled ? $t('enabled') : $t('disabled')} color={enabled ? 'primary' : 'secondary'}>
<Switch bind:checked={enabled} />
</Field>
</div>
<Field label={$t('name')} required>
<Input
placeholder={$t('workflow_name')}
bind:value={() => name ?? '', (value) => (name = value || null)}
/>
</Field>
</div>
<Field label={$t('description')} for="workflow-description">
<Textarea
id="workflow-description"
grow
placeholder={$t('workflow_description')}
bind:value={() => description ?? '', (value) => (description = value || null)}
/>
</Field>
</VStack>
</CardBody>
</Card>
<Field label={$t('name')} required>
<Input
placeholder={$t('workflow_name')}
bind:value={() => name ?? '', (value) => (name = value || null)}
<div class="my-4 h-px w-[98%] bg-light-200"></div>
<Card class="shadow-none">
<CardHeader>
<div class="flex items-center gap-3">
<div class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-success-50">
<Icon icon={mdiFlashOutline} size="20" class="text-success" />
</div>
<div class="flex min-w-0 flex-1 flex-col">
<CardTitle class="truncate">{getTriggerName($t, trigger)}</CardTitle>
<CardDescription class="truncate">{getTriggerDescription($t, trigger)}</CardDescription>
</div>
<IconButton
icon={mdiPencilOutline}
aria-label={$t('edit')}
variant="ghost"
shape="round"
color="secondary"
size="small"
onclick={onChangeTrigger}
/>
</Field>
<Field label={$t('description')} for="workflow-description">
<Textarea
id="workflow-description"
grow
placeholder={$t('workflow_description')}
bind:value={() => description ?? '', (value) => (description = value || null)}
/>
</Field>
</VStack>
</CardBody>
</Card>
<div class="my-4 h-px w-[98%] bg-light-200"></div>
<Card>
<CardHeader class="bg-success-50">
<div class="flex items-start gap-3">
<Icon icon={mdiFlashOutline} size="20" class="mt-1 text-success" />
<div class="flex grow flex-col">
<CardTitle class="text-left text-success">{$t('trigger')}</CardTitle>
<CardDescription>{$t('trigger_description')}</CardDescription>
</div>
<div class="flex items-center justify-end">
<Button leadingIcon={mdiPencilOutline} size="small" color="secondary" onclick={onChangeTrigger}>
{$t('edit')}
</Button>
</div>
</div>
</CardHeader>
</CardHeader>
</Card>
<CardBody>
<div class="flex flex-col items-start">
<Text>{getTriggerName($t, trigger)}</Text>
<Text size="small" color="muted">{getTriggerDescription($t, trigger)}</Text>
</div>
</CardBody>
</Card>
{#each steps as step, index (index)}
<WorkflowStepCard
{step}
{index}
isDragging={draggedIndex === index}
isDragHandleHovered={dragHandleHoverIndex === index}
isDropTarget={dropTargetIndex === index && draggedIndex !== null && draggedIndex !== index}
onEdit={handleEditStep}
onDelete={handleDeleteStep}
onInsertBefore={handleInsertStep}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onDragHandleEnter={(i) => (dragHandleHoverIndex = i)}
onDragHandleLeave={() => (dragHandleHoverIndex = null)}
/>
{/each}
<Card>
<CardHeader class="bg-primary-50">
<div class="flex items-start gap-3">
<Icon icon={mdiFormatListBulletedSquare} size="20" class="mt-1 text-primary" />
<CardTitle class="text-left text-primary">{$t('steps')}</CardTitle>
</div>
</CardHeader>
<CardBody>
{#if steps.length === 0}
<Button leadingIcon={mdiPlus} onclick={handleAddStep}>{$t('add_step')}</Button>
{:else}
<Stack gap={2}>
{#each steps as step, index (index)}
{@const method = pluginManager.getMethod(step.method)}
{#if index > 0}
<hr />
{/if}
<div
// {@attach dragAndDrop({
// index,
// onDragStart: handleFilterDragStart,
// onDragEnter: handleFilterDragEnter,
// onDrop: handleFilterDrop,
// onDragEnd: handleFilterDragEnd,
// isDragging: draggedIndex === index,
// isDragOver: dragOverIndex === index,
// })}
class="flex cursor-move justify-between gap-2 rounded-2xl border-2 border-dashed bg-light-50 p-4 transition-all hover:border-light-300"
>
<div class="flex flex-col gap-1">
<Text>{pluginManager.getMethodLabel(step.method)}</Text>
{#if method?.description}
<Text color="muted" size="small">{method.description}</Text>
{/if}
</div>
<div class="flex gap-1">
<IconButton
icon={mdiPencilOutline}
aria-label={$t('edit')}
variant="ghost"
shape="round"
color="secondary"
onclick={() => handleEditStep(step)}
/>
<IconButton
icon={mdiTrashCanOutline}
aria-label={$t('delete')}
variant="ghost"
shape="round"
color="danger"
onclick={() => handleDeleteStep(index)}
/>
</div>
</div>
{/each}
<Button size="small" fullWidth variant="ghost" leadingIcon={mdiPlus} onclick={handleAddStep}>
{$t('add_step')}
</Button>
</Stack>
{/if}
</CardBody>
</Card>
<Button
size="small"
fullWidth
variant="ghost"
leadingIcon={mdiPlus}
class="border border-dashed"
onclick={handleAddStep}
>
{$t('add_step')}
</Button>
{:else}
<WorkflowJsonEditor jsonContent={workflowJsonContent} onContentChange={handleJsonContentChange} />
{/if}
</VStack>
</Container>
<WorkflowStepDragImage
bind:ref={dragImageElement}
description={dragImage.description}
isFilter={dragImage.isFilter}
label={dragImage.label}
stepNumber={dragImage.stepNumber}
/>
<WorkflowSummary workflow={workflowSummary} />
</AppShell>
@@ -1,4 +1,4 @@
import { searchWorkflows } from '@immich/sdk';
import { getWorkflow } from '@immich/sdk';
import { redirect } from '@sveltejs/kit';
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
import { Route } from '$lib/route';
@@ -8,7 +8,7 @@ import type { PageLoad } from './$types';
export const load = (async ({ url, params, depends }) => {
await authenticate(url);
const [[workflow]] = await Promise.all([searchWorkflows({ id: params.workflowId }), pluginManager.ready()]);
const [workflow] = await Promise.all([getWorkflow({ id: params.workflowId }), pluginManager.ready()]);
const $t = await getFormatter();
if (!workflow) {
@@ -1,7 +1,6 @@
<script lang="ts">
import type { WorkflowResponseDto } from '@immich/sdk';
import { WorkflowTrigger, type WorkflowStepDto, type WorkflowUpdateDto } from '@immich/sdk';
import {
Button,
Card,
CardBody,
CardDescription,
@@ -13,40 +12,91 @@
VStack,
} from '@immich/ui';
import { mdiCodeJson } from '@mdi/js';
import { isEqual } from 'lodash-es';
import { untrack } from 'svelte';
import { JSONEditor, Mode, type Content, type OnChangeStatus } from 'svelte-jsoneditor';
import { t } from 'svelte-i18n';
type WorkflowJsonContent = Required<
Pick<WorkflowUpdateDto, 'description' | 'enabled' | 'name' | 'steps' | 'trigger'>
>;
type Props = {
jsonContent: WorkflowResponseDto;
onApply: () => void;
onContentChange: (content: WorkflowResponseDto) => void;
jsonContent: WorkflowJsonContent;
onContentChange: (content: WorkflowJsonContent) => void;
};
let { jsonContent, onApply, onContentChange }: Props = $props();
let { jsonContent, onContentChange }: Props = $props();
let content: Content = $derived({ json: jsonContent });
let canApply = $state(false);
let content: Content = $state({ json: jsonContent });
let editorClass = $derived(themeManager.value === Theme.Dark ? 'jse-theme-dark' : '');
const isWorkflowStep = (value: unknown): value is WorkflowStepDto => {
if (!value || typeof value !== 'object') {
return false;
}
const step = value as Partial<WorkflowStepDto>;
return (
typeof step.method === 'string' &&
(step.config === null || (typeof step.config === 'object' && !Array.isArray(step.config))) &&
(step.enabled === undefined || typeof step.enabled === 'boolean')
);
};
const isWorkflowJsonContent = (value: unknown): value is WorkflowJsonContent => {
if (!value || typeof value !== 'object') {
return false;
}
const workflow = value as Partial<WorkflowJsonContent>;
return (
typeof workflow.enabled === 'boolean' &&
(workflow.name === null || typeof workflow.name === 'string') &&
(workflow.description === null || typeof workflow.description === 'string') &&
Object.values(WorkflowTrigger).includes(workflow.trigger as WorkflowTrigger) &&
Array.isArray(workflow.steps) &&
workflow.steps.every(isWorkflowStep)
);
};
const parseContent = (updated: Content) => {
if ('json' in updated) {
return updated.json;
}
return JSON.parse(updated.text);
};
$effect(() => {
const nextContent = jsonContent;
let isSynced = false;
try {
isSynced = isEqual(
untrack(() => parseContent(content)),
nextContent,
);
} catch {
// The editor can be temporarily invalid while typing in text mode.
}
if (!isSynced) {
content = { json: nextContent };
}
});
const handleChange = (updated: Content, _: Content, status: OnChangeStatus) => {
if (status.contentErrors) {
return;
}
canApply = true;
if ('text' in updated && updated.text !== undefined) {
try {
const parsed = JSON.parse(updated.text);
onContentChange(parsed);
} catch (error_) {
console.error('Invalid JSON in text mode:', error_);
}
const parsed = parseContent(updated);
if (!isWorkflowJsonContent(parsed)) {
return;
}
};
const handleApply = () => {
onApply();
canApply = false;
onContentChange(parsed);
};
</script>
@@ -57,17 +107,16 @@
<div class="flex items-start gap-3">
<Icon icon={mdiCodeJson} size="20" class="mt-1" />
<div class="flex flex-col">
<CardTitle>Workflow JSON</CardTitle>
<CardDescription>Edit the workflow configuration directly in JSON format</CardDescription>
<CardTitle>{$t('workflow_json')}</CardTitle>
<CardDescription>{$t('workflow_json_help')}</CardDescription>
</div>
</div>
<Button size="small" color="primary" onclick={handleApply} disabled={!canApply}>Apply Changes</Button>
</div>
</CardHeader>
<CardBody>
<VStack gap={2}>
<div class="h-[600px] w-full overflow-hidden rounded-lg border {editorClass}">
<JSONEditor {content} onChange={handleChange} mainMenuBar={false} mode={Mode.text} />
<JSONEditor bind:content onChange={handleChange} mainMenuBar={false} mode={Mode.text} />
</div>
</VStack>
</CardBody>
@@ -0,0 +1,195 @@
<script lang="ts">
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
import type { WorkflowStepDto } from '@immich/sdk';
import { Badge, Card, CardBody, CardDescription, CardHeader, CardTitle, Icon, IconButton } from '@immich/ui';
import {
mdiAutoFix,
mdiDragVertical,
mdiFilterVariant,
mdiPencilOutline,
mdiPlus,
mdiTrashCanOutline,
} from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
step: WorkflowStepDto;
index: number;
isDragging: boolean;
isDragHandleHovered: boolean;
isDropTarget: boolean;
onEdit: (index: number) => void;
onDelete: (index: number) => void;
onInsertBefore: (index: number) => void;
onDragStart: (index: number, event: DragEvent) => void;
onDragEnd: () => void;
onDragOver: (index: number, event: DragEvent) => void;
onDragLeave: (index: number) => void;
onDrop: (index: number, event: DragEvent) => void;
onDragHandleEnter: (index: number) => void;
onDragHandleLeave: () => void;
};
let {
step,
index,
isDragging,
isDragHandleHovered,
isDropTarget,
onEdit,
onDelete,
onInsertBefore,
onDragStart,
onDragEnd,
onDragOver,
onDragLeave,
onDrop,
onDragHandleEnter,
onDragHandleLeave,
}: Props = $props();
const method = $derived(pluginManager.getMethod(step.method));
const isFilter = $derived(method?.uiHints?.includes('filter') ?? false);
const configEntries = $derived(
Object.entries(step.config ?? {}).filter(([, value]) => value !== null && value !== undefined && value !== ''),
);
const truncate = (input: string, max = 24) => (input.length > max ? input.slice(0, max - 1) + '…' : input);
const formatConfigValue = (value: unknown): string => {
if (value === null || value === undefined) {
return '—';
}
if (typeof value === 'boolean') {
return value ? 'on' : 'off';
}
if (typeof value === 'number') {
return String(value);
}
if (typeof value === 'string') {
return `"${truncate(value)}"`;
}
if (Array.isArray(value)) {
if (value.length === 0) {
return $t('none');
}
const items = value.map((v) => (v !== null && typeof v === 'object' ? '{…}' : String(v)));
const joined = items.join(' · ');
if (joined.length <= 28) {
return `"${joined}"`;
}
return $t('items_count', { values: { count: value.length } });
}
return '{…}';
};
</script>
<div class="group/step-row flex w-full flex-col">
<div class="-mt-4 ml-18 flex w-full items-center gap-4">
<div class="relative flex w-1 shrink-0 justify-start">
<div class="h-10 w-0.5 bg-light-200"></div>
<button
type="button"
class="absolute top-1/2 left-1/2 z-10 -translate-1/2 cursor-pointer rounded-full border border-dashed border-primary-200 bg-light p-0.5 text-primary opacity-0 transition-opacity group-hover/step-row:opacity-100 hover:bg-primary-50"
aria-label={$t('add_step')}
title={$t('add_step')}
onclick={() => onInsertBefore(index)}
>
<Icon icon={mdiPlus} size="14" />
</button>
</div>
</div>
<div
class="w-full transition-all"
class:opacity-40={isDragging}
class:scale-[0.99]={isDragging}
ondragover={(event) => onDragOver(index, event)}
ondragleave={() => onDragLeave(index)}
ondrop={(event) => onDrop(index, event)}
role="listitem"
>
<Card
class="shadow-none transition-colors {isDropTarget
? 'border-primary ring-2 ring-primary-200'
: isDragHandleHovered
? 'border-dashed border-primary'
: ''}"
>
<CardHeader>
<div class="flex items-center gap-2">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex shrink-0 cursor-grab items-center justify-center rounded-md border border-transparent p-1 text-light-400 select-none hover:border-primary-200 hover:bg-primary-50 hover:text-primary active:cursor-grabbing"
aria-label={$t('drag_to_reorder')}
draggable="true"
onmouseenter={() => onDragHandleEnter(index)}
onmouseleave={onDragHandleLeave}
ondragstart={(event) => onDragStart(index, event)}
ondragend={onDragEnd}
title={$t('drag_to_reorder')}
>
<Icon icon={mdiDragVertical} size="20" />
</div>
<div
class="flex size-10 shrink-0 items-center justify-center rounded-lg"
class:bg-primary-50={isFilter}
class:bg-warning-50={!isFilter}
>
<Icon
icon={isFilter ? mdiFilterVariant : mdiAutoFix}
size="20"
class={isFilter ? 'text-primary' : 'text-warning'}
/>
</div>
<div class="flex min-w-0 flex-1 flex-col">
<CardTitle class="truncate">
<span class="mr-1 font-bold text-light-500">{index + 1}</span>
{pluginManager.getMethodLabel(step.method)}
</CardTitle>
{#if method?.description}
<CardDescription class="truncate">{method.description}</CardDescription>
{/if}
</div>
<div class="flex shrink-0 items-center gap-1">
<IconButton
icon={mdiPencilOutline}
aria-label={$t('edit')}
variant="ghost"
shape="round"
color="secondary"
size="small"
onclick={() => onEdit(index)}
/>
<IconButton
icon={mdiTrashCanOutline}
aria-label={$t('delete')}
variant="ghost"
shape="round"
color="danger"
size="small"
onclick={() => onDelete(index)}
/>
</div>
</div>
</CardHeader>
{#if configEntries.length > 0}
<CardBody class="py-3">
<div class="flex flex-wrap items-center gap-1.5">
{#each configEntries as [key, value] (key)}
<Badge
color={isFilter ? 'info' : 'warning'}
shape="round"
size="small"
class="border font-mono {isFilter ? 'border-primary-200' : 'border-warning-200'}"
>
<span class="opacity-60">{key}</span>{formatConfigValue(value)}
</Badge>
{/each}
</div>
</CardBody>
{/if}
</Card>
</div>
</div>
@@ -0,0 +1,43 @@
<script lang="ts">
import { Icon } from '@immich/ui';
import { mdiAutoFix, mdiFilterVariant } from '@mdi/js';
type Props = {
ref?: HTMLElement | null;
description?: string;
isFilter: boolean;
label: string;
stepNumber: number;
};
let { ref = $bindable(null), description, isFilter, label, stepNumber }: Props = $props();
</script>
<div
bind:this={ref}
aria-hidden="true"
class="pointer-events-none fixed top-[-1000px] left-0 flex w-80 items-center gap-2.5 rounded-lg border border-light-200 bg-light px-3 py-2.5 text-sm/5 text-dark shadow-2xl"
>
<div
class="flex size-8 shrink-0 items-center justify-center rounded-lg"
class:bg-primary-50={isFilter}
class:bg-warning-50={!isFilter}
>
<Icon
icon={isFilter ? mdiFilterVariant : mdiAutoFix}
size="18"
class={isFilter ? 'text-primary' : 'text-warning'}
/>
</div>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-2">
<span class="shrink-0 font-bold text-light-500">#{stepNumber}</span>
<span class="truncate font-bold">{label}</span>
</div>
{#if description}
<div class="mt-0.5 truncate text-xs/4 text-light-500">{description}</div>
{/if}
</div>
</div>
@@ -1,137 +1,176 @@
<script lang="ts">
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
import { getTriggerName } from '$lib/utils/workflow';
import type { WorkflowResponseDto } from '@immich/sdk';
import type { WorkflowStepDto, WorkflowTrigger } from '@immich/sdk';
import { Icon, IconButton, Text } from '@immich/ui';
import { mdiClose, mdiFlashOutline, mdiPlayCircleOutline, mdiViewDashboardOutline } from '@mdi/js';
import { mdiCheck, mdiClose, mdiContentCopy, mdiViewDashboardOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition';
type WorkflowSummaryData = {
name: string | null;
description: string | null;
trigger: WorkflowTrigger;
steps: WorkflowStepDto[];
};
type Props = {
workflow: WorkflowResponseDto;
workflow: WorkflowSummaryData;
};
let { workflow }: Props = $props();
const { trigger, steps } = $derived(workflow);
let isOpen = $state(false);
let position = $state({ x: 0, y: 0 });
let isDragging = $state(false);
let dragOffset = $state({ x: 0, y: 0 });
let containerEl: HTMLDivElement | undefined = $state();
const handleMouseDown = (e: MouseEvent) => {
if (!containerEl) {
return;
}
isDragging = true;
const rect = containerEl.getBoundingClientRect();
dragOffset = {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging) {
return;
}
position = {
x: e.clientX - dragOffset.x,
y: e.clientY - dragOffset.y,
};
};
const handleMouseUp = () => {
isDragging = false;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
let justCopied = $state(false);
let copyTimer: ReturnType<typeof setTimeout> | undefined;
let panelElement = $state<HTMLElement | undefined>(undefined);
$effect(() => {
// Initialize position to bottom-right on mount
if (globalThis.window && position.x === 0 && position.y === 0) {
position = {
x: globalThis.innerWidth - 280,
y: globalThis.innerHeight - 400,
};
if (!isOpen) {
return;
}
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.stopPropagation();
event.preventDefault();
isOpen = false;
}
};
const handlePointerDown = (event: PointerEvent) => {
if (panelElement && event.target instanceof Node && !panelElement.contains(event.target)) {
isOpen = false;
}
};
document.addEventListener('keydown', handleKeydown, { capture: true });
document.addEventListener('pointerdown', handlePointerDown);
return () => {
document.removeEventListener('keydown', handleKeydown, { capture: true });
document.removeEventListener('pointerdown', handlePointerDown);
};
});
const formatConfigValue = (value: unknown): string => {
if (value === null || value === undefined) {
return '—';
}
if (typeof value === 'boolean') {
return value ? 'true' : 'false';
}
if (typeof value === 'number') {
return String(value);
}
if (typeof value === 'string') {
return `"${value}"`;
}
if (Array.isArray(value)) {
if (value.length === 0) {
return '[]';
}
return '[' + value.map((v) => (v !== null && typeof v === 'object' ? '{…}' : String(v))).join(', ') + ']';
}
if (typeof value === 'object') {
return JSON.stringify(value);
}
return String(value);
};
const getConfigEntries = (config: WorkflowStepDto['config']) =>
Object.entries(config ?? {}).filter(([, value]) => value !== null && value !== undefined && value !== '');
const asciiSummary = $derived.by(() => {
const lines: string[] = [];
const title = workflow.name ?? $t('no_name');
lines.push(`${title}`);
if (workflow.description) {
lines.push(workflow.description);
}
lines.push('', ' WHEN', ` ⚡ ${getTriggerName($t, workflow.trigger)}`, '', ' THEN');
if (workflow.steps.length === 0) {
lines.push(` ${$t('no_steps')}`);
return lines.join('\n');
}
for (const [i, step] of workflow.steps.entries()) {
const method = pluginManager.getMethod(step.method);
const isFilter = method?.uiHints?.includes('filter') ?? false;
const type = isFilter ? $t('filter') : $t('action');
const label = pluginManager.getMethodLabel(step.method);
lines.push(` [${i + 1}] ${type.toUpperCase()} · ${label}`);
for (const [key, value] of getConfigEntries(step.config)) {
lines.push(` ${key} = ${formatConfigValue(value)}`);
}
if (i < workflow.steps.length - 1) {
lines.push('');
}
}
return lines.join('\n');
});
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(asciiSummary);
justCopied = true;
if (copyTimer) {
clearTimeout(copyTimer);
}
copyTimer = setTimeout(() => (justCopied = false), 1500);
} catch {
// ignore — clipboard may be unavailable
}
};
</script>
{#if isOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={containerEl}
class="fixed hidden w-64 select-none hover:cursor-grab sm:block"
style="left: {position.x}px; top: {position.y}px;"
class:cursor-grabbing={isDragging}
onmousedown={handleMouseDown}
<aside
bind:this={panelElement}
class="fixed inset-y-20 right-4 bottom-4 hidden max-w-lg flex-col overflow-hidden rounded-2xl border border-light-200 bg-light shadow-2xl sm:flex"
transition:fly={{ x: 400, duration: 250 }}
aria-label={$t('workflow_summary')}
>
<div
class="rounded-xl border-2 border-transparent bg-light-50 p-4 shadow-sm transition-all hover:border-dashed hover:border-light-300 hover:shadow-xl"
>
<div class="mb-4 flex cursor-grab items-center justify-between select-none">
<Text size="small" fontWeight="semi-bold">{$t('workflow_summary')}</Text>
<div class="flex items-center gap-1">
<IconButton
icon={mdiClose}
size="small"
variant="ghost"
color="secondary"
title="Close summary"
aria-label="Close summary"
onclick={(e: MouseEvent) => {
e.stopPropagation();
isOpen = false;
}}
/>
</div>
</div>
<div class="space-y-2">
<!-- Trigger -->
<div class="rounded-lg border bg-light-100 p-3">
<div class="mb-1 flex items-center gap-2">
<Icon icon={mdiFlashOutline} size="18" class="text-primary" />
<Text size="tiny" fontWeight="semi-bold">{$t('trigger')}</Text>
</div>
<p class="truncate pl-5 text-sm">{getTriggerName($t, trigger)}</p>
</div>
<!-- Connector -->
<div class="flex justify-center">
<div class="h-3 w-0.5 bg-light-400"></div>
</div>
<!-- Steps -->
{#if steps.length > 0}
<div class="rounded-lg border bg-light-100 p-3">
<div class="mb-2 flex items-center gap-2">
<Icon icon={mdiPlayCircleOutline} size="18" class="text-success" />
<Text size="tiny" fontWeight="semi-bold">{$t('actions')}</Text>
</div>
<div class="space-y-1 pl-5">
{#each steps as step, index (index)}
<div class="flex items-center gap-2">
<span
class="flex size-4 shrink-0 items-center justify-center rounded-full bg-light-200 text-[10px] font-medium"
>{index + 1}</span
>
<p class="truncate text-sm">{step.method}</p>
</div>
{/each}
</div>
</div>
{/if}
<!-- Header -->
<div class="flex shrink-0 items-center justify-between border-b border-light-200 px-4 py-2.5">
<Text size="small" fontWeight="semi-bold" color="muted">{$t('workflow_summary')}</Text>
<div class="flex items-center gap-1">
<IconButton
icon={justCopied ? mdiCheck : mdiContentCopy}
size="small"
variant="ghost"
color={justCopied ? 'success' : 'secondary'}
title={$t('copy_to_clipboard')}
aria-label={$t('copy_to_clipboard')}
onclick={handleCopy}
/>
<IconButton
icon={mdiClose}
size="small"
variant="ghost"
color="secondary"
title="Close summary"
aria-label="Close summary"
onclick={() => (isOpen = false)}
/>
</div>
</div>
</div>
<!-- ASCII body — what you see is what you copy -->
<div class="flex-1 overflow-auto p-4">
<pre
class="m-0 overflow-auto rounded-lg border border-light-200 bg-light-100 px-4 py-3 font-mono text-xs/relaxed whitespace-pre">{asciiSummary}</pre>
</div>
</aside>
{:else}
<button
type="button"
class="fixed right-6 bottom-6 hidden size-14 items-center justify-center rounded-full bg-primary text-light shadow-lg transition-colors hover:bg-primary/90 sm:flex"
title={$t('workflow_summary')}
aria-label={$t('workflow_summary')}
onclick={() => (isOpen = true)}
>
<Icon icon={mdiViewDashboardOutline} size="24" />