Compare commits

...

22 Commits

Author SHA1 Message Date
Alex Tran 04e179c7d6 move template to manifest 2026-05-22 10:38:50 -05:00
Alex Tran 398f613724 simplify 2026-05-21 22:34:25 -05:00
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
28 changed files with 1533 additions and 377 deletions
+5
View File
@@ -976,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.",
@@ -2254,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?",
@@ -2415,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",
@@ -2476,6 +2479,7 @@
"week": "Week",
"welcome": "Welcome",
"welcome_to_immich": "Welcome to Immich",
"when": "When",
"width": "Width",
"wifi_name": "Wi-Fi Name",
"workflow": "Workflow",
@@ -2488,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",
+3
View File
@@ -205,6 +205,7 @@ Class | Method | HTTP request | Description
*PeopleApi* | [**updatePeople**](doc//PeopleApi.md#updatepeople) | **PUT** /people | Update people
*PeopleApi* | [**updatePerson**](doc//PeopleApi.md#updateperson) | **PUT** /people/{id} | Update person
*PluginsApi* | [**getPlugin**](doc//PluginsApi.md#getplugin) | **GET** /plugins/{id} | Retrieve a plugin
*PluginsApi* | [**getTemplates**](doc//PluginsApi.md#gettemplates) | **GET** /plugins/templates | Retrieve workflow templates
*PluginsApi* | [**searchPluginMethods**](doc//PluginsApi.md#searchpluginmethods) | **GET** /plugins/methods | Retrieve plugin methods
*PluginsApi* | [**searchPlugins**](doc//PluginsApi.md#searchplugins) | **GET** /plugins | List all plugins
*QueuesApi* | [**emptyQueue**](doc//QueuesApi.md#emptyqueue) | **DELETE** /queues/{name}/jobs | Empty a queue
@@ -491,6 +492,8 @@ Class | Method | HTTP request | Description
- [PlacesResponseDto](doc//PlacesResponseDto.md)
- [PluginMethodResponseDto](doc//PluginMethodResponseDto.md)
- [PluginResponseDto](doc//PluginResponseDto.md)
- [PluginTemplateResponseDto](doc//PluginTemplateResponseDto.md)
- [PluginTemplateStepResponseDto](doc//PluginTemplateStepResponseDto.md)
- [PurchaseResponse](doc//PurchaseResponse.md)
- [PurchaseUpdate](doc//PurchaseUpdate.md)
- [QueueCommand](doc//QueueCommand.md)
+2
View File
@@ -237,6 +237,8 @@ part 'model/pin_code_setup_dto.dart';
part 'model/places_response_dto.dart';
part 'model/plugin_method_response_dto.dart';
part 'model/plugin_response_dto.dart';
part 'model/plugin_template_response_dto.dart';
part 'model/plugin_template_step_response_dto.dart';
part 'model/purchase_response.dart';
part 'model/purchase_update.dart';
part 'model/queue_command.dart';
+51
View File
@@ -73,6 +73,57 @@ class PluginsApi {
return null;
}
/// Retrieve workflow templates
///
/// Retrieve premade workflow templates provided by installed plugins
///
/// Note: This method returns the HTTP [Response].
Future<Response> getTemplatesWithHttpInfo() async {
// ignore: prefer_const_declarations
final apiPath = r'/plugins/templates';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Retrieve workflow templates
///
/// Retrieve premade workflow templates provided by installed plugins
Future<List<PluginTemplateResponseDto>?> getTemplates() async {
final response = await getTemplatesWithHttpInfo();
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<PluginTemplateResponseDto>') as List)
.cast<PluginTemplateResponseDto>()
.toList(growable: false);
}
return null;
}
/// Retrieve plugin methods
///
/// Retrieve a list of plugin methods
+4
View File
@@ -520,6 +520,10 @@ class ApiClient {
return PluginMethodResponseDto.fromJson(value);
case 'PluginResponseDto':
return PluginResponseDto.fromJson(value);
case 'PluginTemplateResponseDto':
return PluginTemplateResponseDto.fromJson(value);
case 'PluginTemplateStepResponseDto':
return PluginTemplateStepResponseDto.fromJson(value);
case 'PurchaseResponse':
return PurchaseResponse.fromJson(value);
case 'PurchaseUpdate':
+144
View File
@@ -0,0 +1,144 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class PluginTemplateResponseDto {
/// Returns a new [PluginTemplateResponseDto] instance.
PluginTemplateResponseDto({
required this.description,
required this.id,
required this.name,
required this.pluginName,
this.steps = const [],
required this.trigger,
});
/// Template description
String description;
/// Template identifier (pluginName#templateName)
String id;
/// Template name
String name;
/// Owning plugin name
String pluginName;
/// Workflow steps
List<PluginTemplateStepResponseDto> steps;
WorkflowTrigger trigger;
@override
bool operator ==(Object other) => identical(this, other) || other is PluginTemplateResponseDto &&
other.description == description &&
other.id == id &&
other.name == name &&
other.pluginName == pluginName &&
_deepEquality.equals(other.steps, steps) &&
other.trigger == trigger;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(description.hashCode) +
(id.hashCode) +
(name.hashCode) +
(pluginName.hashCode) +
(steps.hashCode) +
(trigger.hashCode);
@override
String toString() => 'PluginTemplateResponseDto[description=$description, id=$id, name=$name, pluginName=$pluginName, steps=$steps, trigger=$trigger]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'description'] = this.description;
json[r'id'] = this.id;
json[r'name'] = this.name;
json[r'pluginName'] = this.pluginName;
json[r'steps'] = this.steps;
json[r'trigger'] = this.trigger;
return json;
}
/// Returns a new [PluginTemplateResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static PluginTemplateResponseDto? fromJson(dynamic value) {
upgradeDto(value, "PluginTemplateResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return PluginTemplateResponseDto(
description: mapValueOfType<String>(json, r'description')!,
id: mapValueOfType<String>(json, r'id')!,
name: mapValueOfType<String>(json, r'name')!,
pluginName: mapValueOfType<String>(json, r'pluginName')!,
steps: PluginTemplateStepResponseDto.listFromJson(json[r'steps']),
trigger: WorkflowTrigger.fromJson(json[r'trigger'])!,
);
}
return null;
}
static List<PluginTemplateResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PluginTemplateResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = PluginTemplateResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, PluginTemplateResponseDto> mapFromJson(dynamic json) {
final map = <String, PluginTemplateResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = PluginTemplateResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of PluginTemplateResponseDto-objects as value to a dart map
static Map<String, List<PluginTemplateResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<PluginTemplateResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = PluginTemplateResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'description',
'id',
'name',
'pluginName',
'steps',
'trigger',
};
}
@@ -0,0 +1,131 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class PluginTemplateStepResponseDto {
/// Returns a new [PluginTemplateStepResponseDto] instance.
PluginTemplateStepResponseDto({
this.config = const {},
this.enabled,
required this.method,
});
/// Step configuration
Map<String, Object>? config;
/// Whether the step is enabled
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
bool? enabled;
/// Step plugin method
String method;
@override
bool operator ==(Object other) => identical(this, other) || other is PluginTemplateStepResponseDto &&
_deepEquality.equals(other.config, config) &&
other.enabled == enabled &&
other.method == method;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(config == null ? 0 : config!.hashCode) +
(enabled == null ? 0 : enabled!.hashCode) +
(method.hashCode);
@override
String toString() => 'PluginTemplateStepResponseDto[config=$config, enabled=$enabled, method=$method]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.config != null) {
json[r'config'] = this.config;
} else {
// json[r'config'] = null;
}
if (this.enabled != null) {
json[r'enabled'] = this.enabled;
} else {
// json[r'enabled'] = null;
}
json[r'method'] = this.method;
return json;
}
/// Returns a new [PluginTemplateStepResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static PluginTemplateStepResponseDto? fromJson(dynamic value) {
upgradeDto(value, "PluginTemplateStepResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return PluginTemplateStepResponseDto(
config: mapCastOfType<String, Object>(json, r'config'),
enabled: mapValueOfType<bool>(json, r'enabled'),
method: mapValueOfType<String>(json, r'method')!,
);
}
return null;
}
static List<PluginTemplateStepResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PluginTemplateStepResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = PluginTemplateStepResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, PluginTemplateStepResponseDto> mapFromJson(dynamic json) {
final map = <String, PluginTemplateStepResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = PluginTemplateStepResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of PluginTemplateStepResponseDto-objects as value to a dart map
static Map<String, List<PluginTemplateStepResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<PluginTemplateStepResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = PluginTemplateStepResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'config',
'method',
};
}
+107
View File
@@ -8839,6 +8839,50 @@
"x-immich-permission": "plugin.read"
}
},
"/plugins/templates": {
"get": {
"description": "Retrieve premade workflow templates provided by installed plugins",
"operationId": "getTemplates",
"parameters": [],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/PluginTemplateResponseDto"
},
"type": "array"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Retrieve workflow templates",
"tags": [
"Plugins"
],
"x-immich-history": [
{
"version": "v3.0.0",
"state": "Added"
}
],
"x-immich-permission": "plugin.read"
}
},
"/plugins/{id}": {
"get": {
"description": "Retrieve information about a specific plugin by its ID.",
@@ -20152,6 +20196,69 @@
],
"type": "object"
},
"PluginTemplateResponseDto": {
"properties": {
"description": {
"description": "Template description",
"type": "string"
},
"id": {
"description": "Template identifier (pluginName#templateName)",
"type": "string"
},
"name": {
"description": "Template name",
"type": "string"
},
"pluginName": {
"description": "Owning plugin name",
"type": "string"
},
"steps": {
"description": "Workflow steps",
"items": {
"$ref": "#/components/schemas/PluginTemplateStepResponseDto"
},
"type": "array"
},
"trigger": {
"$ref": "#/components/schemas/WorkflowTrigger",
"description": "Workflow trigger"
}
},
"required": [
"description",
"id",
"name",
"pluginName",
"steps",
"trigger"
],
"type": "object"
},
"PluginTemplateStepResponseDto": {
"properties": {
"config": {
"additionalProperties": {},
"description": "Step configuration",
"nullable": true,
"type": "object"
},
"enabled": {
"description": "Whether the step is enabled",
"type": "boolean"
},
"method": {
"description": "Step plugin method",
"type": "string"
}
},
"required": [
"config",
"method"
],
"type": "object"
},
"PurchaseResponse": {
"properties": {
"hideBuyButtonUntil": {
+21
View File
@@ -254,5 +254,26 @@
}
}
}
],
"templates": [
{
"name": "Archive screenshots to album",
"description": "Add uploads with \"screenshot\" in the filename to an album and archive them",
"trigger": "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 }
}
]
}
]
}
+35
View File
@@ -1514,6 +1514,30 @@ export type PluginResponseDto = {
/** Plugin version */
version: string;
};
export type PluginTemplateStepResponseDto = {
/** Step configuration */
config: {
[key: string]: any;
} | null;
/** Whether the step is enabled */
enabled?: boolean;
/** Step plugin method */
method: string;
};
export type PluginTemplateResponseDto = {
/** Template description */
description: string;
/** Template identifier (pluginName#templateName) */
id: string;
/** Template name */
name: string;
/** Owning plugin name */
pluginName: string;
/** Workflow steps */
steps: PluginTemplateStepResponseDto[];
/** Workflow trigger */
trigger: WorkflowTrigger;
};
export type QueueResponseDto = {
/** Whether the queue is paused */
isPaused: boolean;
@@ -5242,6 +5266,17 @@ export function searchPluginMethods({ description, enabled, id, name, pluginName
...opts
}));
}
/**
* Retrieve workflow templates
*/
export function getTemplates(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: PluginTemplateResponseDto[];
}>("/plugins/templates", {
...opts
}));
}
/**
* Retrieve a plugin
*/
@@ -6,6 +6,7 @@ import {
PluginMethodSearchDto,
PluginResponseDto,
PluginSearchDto,
PluginTemplateResponseDto,
} from 'src/dtos/plugin.dto';
import { Permission } from 'src/enum';
import { Authenticated } from 'src/middleware/auth.guard';
@@ -39,6 +40,17 @@ export class PluginController {
return this.service.searchMethods(dto);
}
@Get('templates')
@Authenticated({ permission: Permission.PluginRead })
@Endpoint({
summary: 'Retrieve workflow templates',
description: 'Retrieve premade workflow templates provided by installed plugins',
history: HistoryBuilder.v3(),
})
getTemplates(): Promise<PluginTemplateResponseDto[]> {
return this.service.getTemplates();
}
@Get(':id')
@Authenticated({ permission: Permission.PluginRead })
@Endpoint({
+19 -1
View File
@@ -1,6 +1,6 @@
import { createZodDto } from 'nestjs-zod';
import { JsonSchemaSchema } from 'src/dtos/json-schema.dto';
import { WorkflowTypeSchema } from 'src/enum';
import { WorkflowTriggerSchema, WorkflowTypeSchema } from 'src/enum';
import z from 'zod';
const pluginNameRegex = /^[a-z0-9-]+[a-z0-9]$/;
@@ -23,6 +23,23 @@ const PluginManifestMethodSchema = z
})
.meta({ id: 'PluginManifestMethodDto' });
const PluginManifestTemplateStepSchema = z
.object({
method: z.string().min(1).describe('Step plugin method (pluginName#methodName)'),
config: z.record(z.string(), z.unknown()).nullable().optional().describe('Step configuration'),
enabled: z.boolean().optional().describe('Whether the step is enabled'),
})
.meta({ id: 'PluginManifestTemplateStepDto' });
const PluginManifestTemplateSchema = z
.object({
name: z.string().min(1).describe('Template name'),
description: z.string().min(1).describe('Template description'),
trigger: WorkflowTriggerSchema.describe('Workflow trigger'),
steps: z.array(PluginManifestTemplateStepSchema).describe('Workflow steps'),
})
.meta({ id: 'PluginManifestTemplateDto' });
const PluginManifestSchema = z
.object({
name: z
@@ -39,6 +56,7 @@ const PluginManifestSchema = z
wasmPath: z.string().min(1).describe('WASM file path'),
author: z.string().min(1).describe('Plugin author'),
methods: z.array(PluginManifestMethodSchema).optional().default([]).describe('Plugin methods'),
templates: z.array(PluginManifestTemplateSchema).optional().default([]).describe('Workflow templates'),
})
.meta({ id: 'PluginManifestDto' });
+48 -1
View File
@@ -1,6 +1,6 @@
import { createZodDto } from 'nestjs-zod';
import { JsonSchemaDto } from 'src/dtos/json-schema.dto';
import { WorkflowTriggerSchema, WorkflowType, WorkflowTypeSchema } from 'src/enum';
import { WorkflowTrigger, WorkflowTriggerSchema, WorkflowType, WorkflowTypeSchema } from 'src/enum';
import { asMethodString } from 'src/utils/workflow';
import z from 'zod';
@@ -43,6 +43,25 @@ const PluginResponseSchema = z
})
.meta({ id: 'PluginResponseDto' });
const PluginTemplateStepResponseSchema = z
.object({
method: z.string().describe('Step plugin method'),
config: z.record(z.string(), z.unknown()).nullable().describe('Step configuration'),
enabled: z.boolean().optional().describe('Whether the step is enabled'),
})
.meta({ id: 'PluginTemplateStepResponseDto' });
const PluginTemplateResponseSchema = z
.object({
id: z.string().describe('Template identifier (pluginName#templateName)'),
pluginName: z.string().describe('Owning plugin name'),
name: z.string().describe('Template name'),
description: z.string().describe('Template description'),
trigger: WorkflowTriggerSchema.describe('Workflow trigger'),
steps: z.array(PluginTemplateStepResponseSchema).describe('Workflow steps'),
})
.meta({ id: 'PluginTemplateResponseDto' });
const PluginMethodSearchSchema = z
.object({
id: z.uuidv4().optional().describe('Plugin method ID'),
@@ -61,6 +80,34 @@ export class PluginSearchDto extends createZodDto(PluginSearchSchema) {}
export class PluginResponseDto extends createZodDto(PluginResponseSchema) {}
export class PluginMethodSearchDto extends createZodDto(PluginMethodSearchSchema) {}
export class PluginMethodResponseDto extends createZodDto(PluginMethodResponseSchema) {}
export class PluginTemplateResponseDto extends createZodDto(PluginTemplateResponseSchema) {}
export type PluginTemplate = {
pluginName: string;
name: string;
description: string;
trigger: WorkflowTrigger;
steps: Array<{
method: string;
config?: Record<string, unknown> | null;
enabled?: boolean;
}>;
};
export const mapTemplate = (template: PluginTemplate): PluginTemplateResponseDto => {
return {
id: `${template.pluginName}#${template.name}`,
pluginName: template.pluginName,
name: template.name,
description: template.description,
trigger: template.trigger,
steps: template.steps.map((step) => ({
method: step.method,
config: step.config ?? null,
enabled: step.enabled,
})),
};
};
type Plugin = {
id: string;
+33
View File
@@ -1,11 +1,16 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { join } from 'node:path';
import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto';
import {
mapMethod,
mapPlugin,
mapTemplate,
PluginMethodResponseDto,
PluginMethodSearchDto,
PluginResponseDto,
PluginSearchDto,
PluginTemplate,
PluginTemplateResponseDto,
} from 'src/dtos/plugin.dto';
import { BaseService } from 'src/services/base.service';
import { isMethodCompatible } from 'src/utils/workflow';
@@ -31,4 +36,32 @@ export class PluginService extends BaseService {
.filter((method) => !dto.trigger || isMethodCompatible(method, dto.trigger))
.map((method) => mapMethod(method));
}
async getTemplates(): Promise<PluginTemplateResponseDto[]> {
const templates = await this.loadTemplates();
return templates.map((template) => mapTemplate(template));
}
private async loadTemplates(): Promise<PluginTemplate[]> {
const { resourcePaths } = this.configRepository.getEnv();
try {
const templates: PluginTemplate[] = [];
const dto = await this.storageRepository.readJsonFile(join(resourcePaths.corePlugin, 'manifest.json'));
const result = PluginManifestDto.schema.safeParse(dto);
if (!result.success) {
return [];
}
for (const template of result.data.templates) {
templates.push({ ...template, pluginName: result.data.name });
}
return templates;
} catch {
this.logger.warn(`Failed to load plugin templates from folder: ${resourcePaths.corePlugin}`);
return [];
}
}
}
+13 -1
View File
@@ -1,8 +1,10 @@
import {
getTemplates,
getWorkflowTriggers,
searchPluginMethods,
WorkflowTrigger,
type PluginMethodResponseDto,
type PluginTemplateResponseDto,
type WorkflowTriggerResponseDto,
} from '@immich/sdk';
import { t } from 'svelte-i18n';
@@ -16,6 +18,7 @@ class PluginManager {
#methodMap = new SvelteMap<string, PluginMethodResponseDto>();
#methods = $state<PluginMethodResponseDto[]>([]);
#triggers = $state<WorkflowTriggerResponseDto[]>([]);
#templates = $state<PluginTemplateResponseDto[]>([]);
constructor() {
eventManager.on({
@@ -33,6 +36,10 @@ class PluginManager {
return this.#triggers;
}
get templates() {
return this.#templates;
}
ready() {
return this.initialize();
}
@@ -70,7 +77,11 @@ class PluginManager {
}
private async load() {
const [methods, triggers] = await Promise.all([searchPluginMethods({}), getWorkflowTriggers()]);
const [methods, triggers, templates] = await Promise.all([
searchPluginMethods({}),
getWorkflowTriggers(),
getTemplates(),
]);
this.#methods = methods;
for (const method of this.#methods) {
@@ -78,6 +89,7 @@ class PluginManager {
}
this.#triggers = triggers;
this.#templates = templates;
}
}
+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,52 @@
<script lang="ts">
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
import { handleCreateWorkflow } from '$lib/services/workflow.service';
import { type PluginTemplateResponseDto } from '@immich/sdk';
import { FormModal, Icon, ListButton, Text } from '@immich/ui';
import { mdiFlashOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
onClose: () => void;
};
const { onClose }: Props = $props();
let selected = $state<PluginTemplateResponseDto>();
const onSubmit = async () => {
if (!selected) {
return;
}
const response = await handleCreateWorkflow({
trigger: selected.trigger,
steps: selected.steps,
name: selected.name,
description: selected.description,
enabled: false,
});
if (response) {
onClose();
}
};
</script>
<FormModal title={$t('workflow_templates')} {onClose} {onSubmit} disabled={!selected} size="medium">
<div class="flex flex-col gap-2">
{#each pluginManager.templates 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={mdiFlashOutline} 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)}>
+11 -3
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,13 @@ export const getWorkflowsActions = ($t: MessageFormatter) => {
}),
};
return { Create };
const UseTemplate: ActionItem = {
title: $t('use_template'),
icon: mdiFileDocumentMultipleOutline,
onAction: () => modalManager.show(WorkflowTemplatePicker, {}),
};
return { Create, UseTemplate };
};
export const getWorkflowActions = ($t: MessageFormatter, workflow: WorkflowResponseDto) => {
@@ -72,12 +79,13 @@ export const getWorkflowShowSchemaAction = (
onAction: onToggle,
});
const handleCreateWorkflow = async (dto: WorkflowCreateDto) => {
export const handleCreateWorkflow = async (dto: WorkflowCreateDto) => {
const $t = await getFormatter();
try {
const response = await createWorkflow({ workflowCreateDto: dto });
eventManager.emit('WorkflowCreate', response);
return response;
} catch (error) {
handleError(error, $t('errors.unable_to_create'));
}
+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" />