Migrate core UI areas to Angular Material

This commit is contained in:
Lucio Lelii 2026-03-12 15:30:31 +01:00
parent dc45a5581b
commit d6e3a2a534
51 changed files with 1567 additions and 716 deletions

123
package-lock.json generated
View File

@ -9,15 +9,17 @@
"version": "0.0.0",
"dependencies": {
"@angular/animations": "^21.0.4",
"@angular/cdk": "^21.0.3",
"@angular/cdk": "^21.0.5",
"@angular/common": "^21.0.4",
"@angular/compiler": "^21.0.4",
"@angular/core": "^21.0.4",
"@angular/elements": "^21.0.8",
"@angular/forms": "^21.0.4",
"@angular/material": "^21.0.5",
"@angular/platform-browser": "^21.0.4",
"@angular/platform-browser-dynamic": "^21.0.4",
"@angular/router": "^21.0.0",
"@fontsource/material-icons-outlined": "^5.2.6",
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
"class-variance-authority": "^0.7.1",
@ -467,9 +469,9 @@
}
},
"node_modules/@angular/cdk": {
"version": "21.0.3",
"resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.0.3.tgz",
"integrity": "sha512-abfckeZfFvovdpxuQHRE4gS1VLNa05Dx0ZSKLGVL9DsQsi4pgn6wWg1y9TkXMlmtpG/EhLmCBxUc6LOHfdeWQA==",
"version": "21.0.5",
"resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.0.5.tgz",
"integrity": "sha512-yO/IRYEZ5wJkpwg3GT3b6RST4pqNFTAhuyPdEdLcE81cs283K3aKOsCYh2xUR3bR4WxBh2kBPSJ31AFZyJXbSA==",
"license": "MIT",
"dependencies": {
"parse5": "^8.0.0",
@ -638,31 +640,21 @@
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@angular/localize": {
"version": "21.0.6",
"resolved": "https://registry.npmjs.org/@angular/localize/-/localize-21.0.6.tgz",
"integrity": "sha512-t4Jf4px49YAuF7G0eLxknB+KNE+tdqzhAo2w//9kjZGg/xHPp9H/RQUlNUB/RYJuabbBh4IpiRvpJZqp7rKt8A==",
"dev": true,
"node_modules/@angular/material": {
"version": "21.0.5",
"resolved": "https://registry.npmjs.org/@angular/material/-/material-21.0.5.tgz",
"integrity": "sha512-LcDotwwTl2GFtFK1BqcKjj0eRAkOjTSj1C6ux0rOlpU2hJaECxue/ZqEyB/KMHngFAqMJpecxF4zGy/ISCp2xA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@babel/core": "7.28.4",
"@types/babel__core": "7.20.5",
"tinyglobby": "^0.2.12",
"yargs": "^18.0.0"
},
"bin": {
"localize-extract": "tools/bundles/src/extract/cli.js",
"localize-migrate": "tools/bundles/src/migrate/cli.js",
"localize-translate": "tools/bundles/src/translate/cli.js"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/compiler": "21.0.6",
"@angular/compiler-cli": "21.0.6"
"@angular/cdk": "21.0.5",
"@angular/common": "^21.0.0 || ^22.0.0",
"@angular/core": "^21.0.0 || ^22.0.0",
"@angular/forms": "^21.0.0 || ^22.0.0",
"@angular/platform-browser": "^21.0.0 || ^22.0.0",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@angular/platform-browser": {
@ -1694,6 +1686,15 @@
"node": ">=18"
}
},
"node_modules/@fontsource/material-icons-outlined": {
"version": "5.2.6",
"resolved": "https://registry.npmjs.org/@fontsource/material-icons-outlined/-/material-icons-outlined-5.2.6.tgz",
"integrity": "sha512-99XKAkwnCg0s0/ywax+o3m01HSNM5gGzSBw+WnlWG2+WY3wOjcN+wXMfm4zP37Yme7Yze2DvKxF78tHWOrlwFw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@inquirer/ansi": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz",
@ -3356,17 +3357,6 @@
"license": "MIT",
"optional": true
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-beta.47",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.47.tgz",
@ -4021,6 +4011,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
"dev": true,
"license": "MIT"
},
"node_modules/@tailwindcss/node": {
@ -4355,59 +4346,6 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
"integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@babel/parser": "^7.20.7",
"@babel/types": "^7.20.7",
"@types/babel__generator": "*",
"@types/babel__template": "*",
"@types/babel__traverse": "*"
}
},
"node_modules/@types/babel__generator": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
"integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@babel/types": "^7.0.0"
}
},
"node_modules/@types/babel__template": {
"version": "7.4.4",
"resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
"integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@babel/parser": "^7.1.0",
"@babel/types": "^7.0.0"
}
},
"node_modules/@types/babel__traverse": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
"integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@ -10253,13 +10191,6 @@
"peerDependencies": {
"zod": "^3.25 || ^4"
}
},
"node_modules/zone.js": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.16.0.tgz",
"integrity": "sha512-LqLPpIQANebrlxY6jKcYKdgN5DTXyyHAKnnWWjE5pPfEQ4n7j5zn7mOEEpwNZVKGqx3kKKmvplEmoBrvpgROTA==",
"license": "MIT",
"peer": true
}
}
}

View File

@ -24,15 +24,17 @@
"packageManager": "npm@10.8.2",
"dependencies": {
"@angular/animations": "^21.0.4",
"@angular/cdk": "^21.0.3",
"@angular/cdk": "^21.0.5",
"@angular/common": "^21.0.4",
"@angular/compiler": "^21.0.4",
"@angular/core": "^21.0.4",
"@angular/elements": "^21.0.8",
"@angular/forms": "^21.0.4",
"@angular/material": "^21.0.5",
"@angular/platform-browser": "^21.0.4",
"@angular/platform-browser-dynamic": "^21.0.4",
"@angular/router": "^21.0.0",
"@fontsource/material-icons-outlined": "^5.2.6",
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
"class-variance-authority": "^0.7.1",

View File

@ -1,6 +1,7 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { provideAnimations } from '@angular/platform-browser/animations';
import { authTokenInterceptor } from './interceptors/auth-token.interceptor';
import { routes } from './app.routes';
@ -8,6 +9,7 @@ import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideAnimations(),
provideHttpClient(withInterceptors([authTokenInterceptor])),
provideRouter(routes)
]

View File

@ -3,6 +3,12 @@
font-family: 'Roboto', sans-serif;
}
.app-topbar {
height: 56px !important;
min-height: 56px !important;
padding: 0 16px !important;
}
/* Logo */
.logo {
height: 40px;
@ -18,15 +24,44 @@
min-height: 0; /* necessario per overflow-auto su flex */
}
/* Tabs sticky */
.nav-tabs {
border-bottom: 1px solid #ccc;
.app-tabbar-wrap {
position: fixed;
top: 56px;
z-index: 40;
width: 100%;
padding: 0 16px;
background: #d8f4e7;
border-bottom: 1px solid rgba(148, 163, 184, 0.28);
}
.nav-link.active {
background-color: #ffffff;
color: #0f766e; /* verde elegante */
border: 1px solid #d1d5db;
border-bottom-color: white;
box-shadow: 0 -1px 6px rgba(0, 0, 0, 0.05);
.app-tabbar {
min-height: 48px;
padding: 0;
background: transparent;
}
.app-tab-link {
min-width: 120px;
min-height: 48px;
border-radius: 0;
font-weight: 600;
}
.app-shell {
display: flex;
flex: 1;
flex-direction: column;
height: 100vh;
overflow: hidden;
padding-top: 112px;
}
.app-main-content {
width: 100%;
flex: 1 1 auto;
min-height: 0;
margin-top: 0;
padding: 10px 16px 16px;
overflow: hidden;
background: #ffffff;
}

View File

@ -1,72 +1,50 @@
<div class="main-layout h-screen flex flex-col">
<!-- Header fisso -->
<header
class="app-header fixed top-0 left-0 right-0 z-50 flex justify-between items-center px-4 h-16 bg-[#d8f4e7] border-b-none">
<div class="left-section flex items-center gap-4">
<img src="/logoNoName.png" alt="Logo" class="logo h-10" />
<h1 class="app-title text-lg font-medium text-gray-800 m-0">HumAIn Flow</h1>
</div>
<div class="user-section flex items-center gap-2 text-gray-700 font-medium">
<i class="bi bi-person-circle fs-4"></i>
<span class="username">{{ loggedUser()?.username }}</span>
<div class="relative group inline-flex">
<i class="bi bi-box-arrow-right
text-secondary hover:text-red-600
cursor-pointer transition" (click)="logout()" aria-label="Logout" role="button" tabindex="-1">
</i>
<!-- Tooltip -->
<div class="absolute bottom-full left-1/2 -translate-x-1/2 mb-2
opacity-0 group-hover:opacity-100
pointer-events-none
whitespace-nowrap
rounded-md bg-gray-800 px-3 py-1.5
text-xs text-white shadow-lg
transition-opacity duration-200">
Logout
</div>
</div>
</div>
</header>
<!-- Wrapper per compensare header fisso -->
<div class="pt-16 flex flex-col flex-1 h-[calc(100vh-64px)] overflow-hidden">
<ul class="nav fixed w-full top-16 z-40
flex gap-1 px-3
bg-[#d8f4e7] border-b-none shadow-sm h-10">
<li class="nav-item">
<a routerLink="/editor" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }" tabindex="-1" class="nav-link
px-4 py-2 rounded-t-md
text-gray-700 font-medium
transition-all duration-200
hover:bg-white/60">
<i class="bi bi-diagram-3 me-2"></i>
Editor
</a>
</li>
<li class="nav-item">
<a routerLink="/tasks" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }" tabindex="-1" class="nav-link
px-4 py-2 rounded-t-md
text-gray-700 font-medium
transition-all duration-200
hover:bg-white/60">
<i class="bi bi-list-check me-2"></i>
Tasks
</a>
</li>
</ul>
<!-- Contenuto principale scrollabile -->
<main class="main-content w-full h-[calc(100vh-104px)] mt-12 overflow-hidden ml-2 bg-white pb-4 pr-4 rounded-md shadow-sm">
<router-outlet></router-outlet>
</main>
<div class="main-layout flex h-screen flex-col">
<mat-toolbar class="app-topbar !fixed !left-0 !right-0 !top-0 !z-50 !bg-[#d8f4e7] !text-slate-800">
<div class="flex items-center gap-4">
<img src="/logoNoName.png" alt="Logo" class="h-10" />
<h1 class="m-0 text-lg font-medium">HumAIn Flow</h1>
</div>
</div>
<span class="flex-1"></span>
<div class="flex items-center gap-3 text-sm font-medium text-gray-700">
<mat-icon fontIcon="account_circle"></mat-icon>
<span class="username">{{ loggedUser()?.username }}</span>
<button mat-stroked-button type="button" (click)="logout()">Logout</button>
</div>
</mat-toolbar>
<div class="app-shell">
<div class="app-tabbar-wrap">
<nav mat-tab-nav-bar [tabPanel]="tabPanel" class="app-tabbar">
<a
mat-tab-link
routerLink="/editor"
routerLinkActive
#editorRla="routerLinkActive"
[routerLinkActiveOptions]="{ exact: true }"
class="app-tab-link"
[active]="editorRla.isActive">
<mat-icon fontIcon="schema"></mat-icon>
Editor
</a>
<a
mat-tab-link
routerLink="/tasks"
routerLinkActive
#tasksRla="routerLinkActive"
[routerLinkActiveOptions]="{ exact: true }"
class="app-tab-link"
[active]="tasksRla.isActive">
<mat-icon fontIcon="task_alt"></mat-icon>
Tasks
</a>
</nav>
<mat-tab-nav-panel #tabPanel></mat-tab-nav-panel>
</div>
<main class="main-content app-main-content">
<router-outlet></router-outlet>
</main>
</div>
</div>

View File

@ -1,10 +1,15 @@
import { Component, inject } from '@angular/core';
import { afterNextRender, Component, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTabsModule } from '@angular/material/tabs';
import { MatToolbarModule } from '@angular/material/toolbar';
import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { Authorization } from '@services/authorization/authorization';
import { BlocksService } from '@services/blocks/blocks';
@Component({
selector: 'app-app-layout',
imports: [RouterOutlet, RouterLink, RouterLinkActive],
imports: [RouterOutlet, RouterLink, RouterLinkActive, MatButtonModule, MatIconModule, MatTabsModule, MatToolbarModule],
templateUrl: './app-layout.html',
styleUrl: './app-layout.css',
})
@ -13,8 +18,17 @@ export class AppLayout {
router = inject(Router);
private authService = inject(Authorization);
private blocksService = inject(BlocksService);
loggedUser = this.authService.loggedInUser;
constructor() {
afterNextRender(() => {
void this.blocksService.getAllBlocksTypes().catch((err) => {
console.error('Blocks preload failed', err);
});
});
}
logout() {
this.authService.logout();

View File

@ -12,12 +12,12 @@
<!-- CENTER EDITOR -->
<main class="flow-editor-main">
@if (flow()) {
<div class="flow-editor-canvas-shell">
<mat-card class="flow-editor-canvas-shell">
<app-title-toolbar></app-title-toolbar>
<div class="flow-editor-canvas">
<app-rete-editor [flowId]="flow()!.id" [flowData]="flow()!.data"></app-rete-editor>
</div>
</div>
</mat-card>
} @else {
<div class="flow-editor-empty">
<h2>No flow selected</h2>
@ -31,9 +31,10 @@
<button
type="button"
class="flow-editor-assistant-toggle"
mat-stroked-button
(click)="toggleAssistant()"
[attr.aria-label]="assistantOpen() ? 'Collapse assistant' : 'Expand assistant'">
<i class="bi" [class.bi-layout-sidebar-inset-reverse]="assistantOpen()" [class.bi-stars]="!assistantOpen()"></i>
<mat-icon [fontIcon]="assistantOpen() ? 'right_panel_close' : 'auto_awesome'"></mat-icon>
<span>{{ assistantOpen() ? 'Hide' : 'AI' }}</span>
</button>
@ -41,9 +42,9 @@
<app-flow-assistant></app-flow-assistant>
} @else {
<div class="flow-editor-assistant-rail">
<i class="bi bi-stars flow-editor-assistant-rail-icon" aria-hidden="true"></i>
<mat-icon class="flow-editor-assistant-rail-icon" fontIcon="auto_awesome" aria-hidden="true"></mat-icon>
<div class="flow-editor-assistant-rail-text">AI</div>
<i class="bi bi-magic flow-editor-assistant-rail-wand" aria-hidden="true"></i>
<mat-icon class="flow-editor-assistant-rail-wand" fontIcon="magic_button" aria-hidden="true"></mat-icon>
</div>
}
</section>

View File

@ -1,4 +1,7 @@
import { Component, inject, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { environment } from '@environment';
import { EditorSidebar } from "@pages/main/editor-sidebar/editor-sidebar";
import { EditorStateHolder } from '@stores/flow-editor';
@ -8,7 +11,7 @@ import { ReteEditor } from "@shared/rete-editor/rete-editor";
@Component({
selector: 'app-flow-editor',
imports: [EditorSidebar, TitleToolbar, ReteEditor, FlowAssistant],
imports: [EditorSidebar, TitleToolbar, ReteEditor, FlowAssistant, MatButtonModule, MatCardModule, MatIconModule],
templateUrl: './flow-editor.html',
styleUrl: './flow-editor.css',
})

View File

@ -1,15 +1,15 @@
<div class="flex flex-col flex-1 h-full">
<div class="flex flex-row flex-1 overflow-hidden gap-2">
<app-tasks-executions-list
class="w-[320px] shrink-0"
class="w-[360px] shrink-0"
[executions]="executions()"
[selectedExecutionId]="selectedExecutionId()"
(executionSelected)="selectExecution($event)"
(executionDeleteRequested)="removeExecution($event)">
</app-tasks-executions-list>
<main class="flex flex-col w-full bg-gray-100 overflow-hidden rounded-md">
<mat-card class="flex w-full flex-col overflow-hidden !rounded-md !bg-gray-100">
<app-task-execution-viewer [execution]="selectedExecution()"></app-task-execution-viewer>
</main>
</mat-card>
</div>
</div>

View File

@ -1,4 +1,5 @@
import { Component, computed, effect, inject, signal } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { normalizeExecutionStatus, TaskExecution } from '@models/task-execution';
import {
TaskExecutionListItem,
@ -10,7 +11,7 @@ import { TaskExecutionsService } from '@services/task-executions/task-executions
@Component({
selector: 'app-tasks-executor',
imports: [TasksExecutionsListComponent, TaskExecutionViewerComponent],
imports: [TasksExecutionsListComponent, TaskExecutionViewerComponent, MatCardModule],
templateUrl: './tasks-executor.html',
styleUrl: './tasks-executor.css',
})

View File

@ -3,3 +3,99 @@
transform: translateY(20px);
transition: opacity 500ms ease-out, transform 500ms ease-out;
}
.auth-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 16px;
}
.auth-card {
width: 100%;
max-width: 32rem;
margin: 0 auto;
padding: 28px;
border: 1px solid #bae6fd;
border-radius: 24px;
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.14);
}
.auth-logo-wrap {
display: flex;
justify-content: center;
}
.auth-logo {
width: 12rem;
height: 12rem;
object-fit: contain;
}
.auth-card-body {
padding: 16px;
}
.auth-field,
.auth-submit {
width: 100%;
}
.auth-links {
margin-bottom: 12px;
text-align: center;
}
.auth-link {
margin-left: 4px;
color: #2563eb;
text-decoration: none;
}
.auth-link:hover {
text-decoration: underline;
}
.auth-banner {
position: fixed;
top: 16px;
left: 50%;
transform: translateX(-50%);
z-index: 50;
display: flex;
align-items: flex-start;
gap: 12px;
width: 100%;
max-width: 32rem;
padding: 12px 16px;
border-radius: 10px;
border: 1px solid;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.14);
}
.auth-banner-error {
border-color: #fca5a5;
background: #fee2e2;
color: #991b1b;
}
.auth-banner-success {
border-color: #86efac;
background: #dcfce7;
color: #166534;
}
.auth-banner-icon {
margin-top: 2px;
}
.auth-banner-body {
flex: 1;
font-size: 14px;
line-height: 1.5;
}
.auth-banner-body p {
margin: 0;
}

View File

@ -1,83 +1,57 @@
@if (error()) {
<div class="fixed top-4 left-1/2 -translate-x-1/2 z-50
w-full max-w-md
rounded-lg border border-red-300
bg-red-100 text-red-800
shadow-md px-4 py-3
flex items-start gap-3">
<!-- Icon -->
<i class="bi bi-exclamation-triangle-fill text-red-600 mt-0.5"></i>
<!-- Message -->
<div class="flex-1 text-sm leading-relaxed">
<p class="m-0">
{{ error() }}
</p>
<div class="auth-banner auth-banner-error">
<mat-icon class="auth-banner-icon" fontIcon="warning"></mat-icon>
<div class="auth-banner-body">
<p>{{ error() }}</p>
</div>
</div>
}
@if (registeredUser()) {
<div class="fixed top-4 left-1/2 -translate-x-1/2 z-50
w-full max-w-md
rounded-lg border border-green-300
bg-green-100 text-green-800
shadow-md px-4 py-3
flex items-start gap-3">
<!-- Icon -->
<i class="bi bi-check-circle-fill text-green-600 mt-0.5"></i>
<!-- Message -->
<div class="flex-1 text-sm leading-relaxed">
<p class="m-0">
<div class="auth-banner auth-banner-success">
<mat-icon class="auth-banner-icon" fontIcon="check_circle"></mat-icon>
<div class="auth-banner-body">
<p>
Registration successful. <b>{{ registeredUser() }}</b>, now you can log in.
</p>
</div>
</div>
}
<div class="flex justify-center items-center min-h-screen bg-base-200 p-4">
<div class="border-2 border-sky-500/50 w-full max-w-md mx-auto p-7 shadow-xl rounded-md">
<div class="flex justify-center">
<img class="size-48 " alt="" src="/logoNoName.png" />
<div class="auth-page">
<mat-card class="auth-card">
<div class="auth-logo-wrap">
<img class="auth-logo" alt="" src="/logoNoName.png" />
</div>
<div class="card-body p-4">
<div class="auth-card-body">
<form (ngSubmit)="onSubmit()" novalidate>
<!-- Email -->
<div class="input-group mb-3">
<span class="input-group-text"><i class="bi bi-at"></i></span>
<input type="text" id="username" class="form-control" placeholder="Username"
[field]="loginForm.username" />
</div>
<!-- Password -->
<div class="input-group mb-3">
<span class="input-group-text"><i class="bi bi-key"></i></span>
<input type="password" #password id="password" class="form-control" placeholder="Password"
[field]="loginForm.password" />
<button class="input-group-text bg-white" type="button" tabindex="-1"
<mat-form-field appearance="outline" class="auth-field">
<mat-label>Username</mat-label>
<input matInput type="text" id="username" placeholder="Username" [field]="loginForm.username" />
</mat-form-field>
<mat-form-field appearance="outline" class="auth-field">
<mat-label>Password</mat-label>
<input matInput type="password" #password id="password" placeholder="Password" [field]="loginForm.password" />
<button mat-button matSuffix type="button" tabindex="-1"
(click)="password.type = password.type === 'password' ? 'text' : 'password'"
[disabled]="loginFormModel().password.length === 0">
<i
class="bi"
[class.bi-eye]="password.type === 'password'"
[class.bi-eye-slash]="password.type === 'text'"></i>
{{ password.type === 'password' ? 'Show' : 'Hide' }}
</button>
</div>
</mat-form-field>
<!-- Sign up -->
<div class="mb-3 text-center">
<div class="auth-links">
<span>
Don't have an account yet?
<a routerLink="/sign-up" class="ms-1 link-primary">Sign Up</a>
<a routerLink="/sign-up" class="auth-link">Sign Up</a>
</span>
</div>
<!-- Submit -->
<button type="submit" class="btn btn-outline-primary w-100" [disabled]="loginForm().invalid()">
<button type="submit" mat-flat-button class="auth-submit" [disabled]="loginForm().invalid()">
Login
</button>
</form>
</div>
</div>
</mat-card>
</div>

View File

@ -1,5 +1,10 @@
import { Component, effect, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { Router, RouterLink } from "@angular/router";
import { FormUtility } from '@utilities/form-utility';
import { Authorization } from '@services/authorization/authorization';
@ -8,7 +13,7 @@ import { Field, form, required } from '@angular/forms/signals';
@Component({
selector: 'app-login',
imports: [FormsModule, RouterLink, Field],
imports: [FormsModule, RouterLink, Field, MatButtonModule, MatCardModule, MatFormFieldModule, MatIconModule, MatInputModule],
templateUrl: './login.html',
styleUrls: ['./login.css'],
})

View File

@ -0,0 +1,78 @@
.signup-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 16px;
}
.signup-card {
width: 100%;
max-width: 48rem;
padding: 28px;
border: 1px solid #bae6fd;
border-radius: 24px;
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.14);
}
.signup-logo-wrap {
display: flex;
justify-content: center;
}
.signup-logo {
width: 12rem;
height: 12rem;
object-fit: contain;
}
.signup-card-body {
padding: 16px;
}
.signup-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.signup-row {
display: flex;
flex-direction: column;
gap: 12px;
}
.signup-field,
.signup-submit {
width: 100%;
}
.signup-links {
margin-bottom: 4px;
text-align: center;
}
.signup-link {
margin-left: 4px;
color: #2563eb;
text-decoration: none;
}
.signup-link:hover {
text-decoration: underline;
}
.signup-error {
padding: 16px;
border: 1px solid #fecaca;
border-radius: 10px;
background: #fef2f2;
color: #991b1b;
font-size: 14px;
}
@media (min-width: 1024px) {
.signup-row {
flex-direction: row;
}
}

View File

@ -1,113 +1,88 @@
<div class="flex justify-center items-center min-h-screen bg-base-400 p-4">
<div class="border-2 border-sky-500/50 w-3/5 min-w-100 mx-auto p-7 shadow-xl rounded-md">
<div class="flex justify-center">
<img class="size-48 " alt="" src="/logoNoName.png" />
</div>
<div class="card-body p-4">
<form (ngSubmit)="onSubmit()" class="flex flex-col gap-3">
<div class="mb-3">
<label for="fullname" class="form-label">Full Name</label>
<input id="fullname" type="text" class="form-control"
[class.is-invalid]="isInvalid(signupForm.fullname())" [field]="signupForm.fullname"
placeholder="Paolo Rossi" />
@if (isInvalid(signupForm.fullname())) {
<small class="invalid-feedback">{{ signupForm.fullname().errors()[0].message }}</small>
}
</div>
<div class="flex lg:flex-row flex-col gap-3">
<div class="mb-3 w-full">
<label for="email" class="form-label">Email</label>
<input id="email" type="email" class="form-control"
[class.is-invalid]="isInvalid(signupForm.email())" [field]="signupForm.email"
placeholder="email@example.com" />
@if (isInvalid(signupForm.email())) {
<small class="invalid-feedback">{{ signupForm.email().errors()[0].message }}</small>
}
</div>
<div class="mb-3 w-full">
<label for="username" class="form-label">Username</label>
<input id="username" type="text" class="form-control"
[class.is-invalid]="isInvalid(signupForm.username())" [field]="signupForm.username"
placeholder="Username" />
@if (isInvalid(signupForm.username())) {
<small class="invalid-feedback">{{ signupForm.username().errors()[0].message }}</small>
}
</div>
</div>
<div class="flex lg:flex-row flex-col gap-3">
<div class="mb-3 w-full">
<label for="password" class="form-label">Password</label>
<div class="input-group">
<input id="password" type="password" class="form-control" #password
[class.is-invalid]="isInvalid(signupForm.password())" [field]="signupForm.password"
placeholder="********" />
<button class="input-group-text bg-white" type="button" tabindex="-1"
(click)="password.type = password.type === 'password' ? 'text' : 'password'"
[disabled]="signupModel().password.length === 0">
<i
class="bi"
[class.bi-eye]="password.type === 'password'"
[class.bi-eye-slash]="password.type === 'text'"></i>
</button>
@if (isInvalid(signupForm.password())) {
<small class="invalid-feedback">{{ signupForm.password().errors()[0].message }}</small>
}
</div>
</div>
<div class="mb-3 w-full">
<label for="confirmPassword" class="form-label">Confirm Password</label>
<div class="input-group">
<input id="confirmPassword" type="password" class="form-control" #confirmPassword
[class.is-invalid]="!isInvalid(signupForm.password()) && isInvalid(signupForm.confirmPassword())"
[field]="signupForm.confirmPassword" placeholder="********" />
<button class="input-group-text bg-white" type="button" tabindex="-1"
(click)="confirmPassword.type = confirmPassword.type === 'password' ? 'text' : 'password'"
[disabled]="signupModel().confirmPassword.length === 0">
<i
class="bi"
[class.bi-eye]="confirmPassword.type === 'password'"
[class.bi-eye-slash]="confirmPassword.type === 'text'"></i>
</button>
@if (!isInvalid(signupForm.password()) && isInvalid(signupForm.confirmPassword())) {
<small class="invalid-feedback">{{
signupForm.confirmPassword().errors()[0].message}}</small>
}
</div>
</div>
</div>
<div class="mb-3 text-center">
<span>
Already have an account?
<a routerLink="/sign-in" class="ms-1 link-primary">Sign In</a>
</span>
</div>
@if(error()) {
<div class="mt-2 bg-red-100 border border-red-200 text-sm text-red-800 rounded-lg p-4 dark:bg-red-800/10 dark:border-red-900 dark:text-red-500"
role="alert" tabindex="-1" aria-labelledby="hs-soft-color-danger-label">
<span id="hs-soft-color-danger-label" class="font-bold">Error</span> {{error()}}
</div>
} @else {
@if (!isRegistering()){
<!-- Submit -->
<button type="submit" class="btn btn-outline-primary w-full p-2" [disabled]="signupForm().invalid()">
Register
</button>
} @else {
<button type="button" class="btn btn-outline-primary w-full p-2" disabled>
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
Registering...
</button>
}
}
</form>
</div>
<div class="signup-page">
<mat-card class="signup-card">
<div class="signup-logo-wrap">
<img class="signup-logo" alt="" src="/logoNoName.png" />
</div>
<div class="signup-card-body">
<form (ngSubmit)="onSubmit()" class="signup-form">
<mat-form-field appearance="outline" class="signup-field">
<mat-label>Full Name</mat-label>
<input matInput id="fullname" type="text" [field]="signupForm.fullname" placeholder="Paolo Rossi" />
@if (isInvalid(signupForm.fullname())) {
<mat-error>{{ signupForm.fullname().errors()[0].message }}</mat-error>
}
</mat-form-field>
<div class="signup-row">
<mat-form-field appearance="outline" class="signup-field">
<mat-label>Email</mat-label>
<input matInput id="email" type="email" [field]="signupForm.email" placeholder="email@example.com" />
@if (isInvalid(signupForm.email())) {
<mat-error>{{ signupForm.email().errors()[0].message }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="signup-field">
<mat-label>Username</mat-label>
<input matInput id="username" type="text" [field]="signupForm.username" placeholder="Username" />
@if (isInvalid(signupForm.username())) {
<mat-error>{{ signupForm.username().errors()[0].message }}</mat-error>
}
</mat-form-field>
</div>
<div class="signup-row">
<mat-form-field appearance="outline" class="signup-field">
<mat-label>Password</mat-label>
<input matInput id="password" type="password" #password [field]="signupForm.password" placeholder="********" />
<button mat-button matSuffix type="button" tabindex="-1"
(click)="password.type = password.type === 'password' ? 'text' : 'password'"
[disabled]="signupModel().password.length === 0">
{{ password.type === 'password' ? 'Show' : 'Hide' }}
</button>
@if (isInvalid(signupForm.password())) {
<mat-error>{{ signupForm.password().errors()[0].message }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="signup-field">
<mat-label>Confirm Password</mat-label>
<input matInput id="confirmPassword" type="password" #confirmPassword [field]="signupForm.confirmPassword" placeholder="********" />
<button mat-button matSuffix type="button" tabindex="-1"
(click)="confirmPassword.type = confirmPassword.type === 'password' ? 'text' : 'password'"
[disabled]="signupModel().confirmPassword.length === 0">
{{ confirmPassword.type === 'password' ? 'Show' : 'Hide' }}
</button>
@if (!isInvalid(signupForm.password()) && isInvalid(signupForm.confirmPassword())) {
<mat-error>{{ signupForm.confirmPassword().errors()[0].message }}</mat-error>
}
</mat-form-field>
</div>
<div class="signup-links">
<span>
Already have an account?
<a routerLink="/sign-in" class="signup-link">Sign In</a>
</span>
</div>
@if(error()) {
<div class="signup-error" role="alert">
<span class="font-bold">Error</span> {{error()}}
</div>
}
@if (!isRegistering()) {
<button type="submit" mat-flat-button class="signup-submit" [disabled]="signupForm().invalid()">
Register
</button>
} @else {
<button type="button" mat-flat-button class="signup-submit" disabled>
Registering...
</button>
}
</form>
</div>
</mat-card>
</div>

View File

@ -1,5 +1,9 @@
import { Component, effect, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { form, Field, minLength, email, required, validate, maxLength, disabled } from '@angular/forms/signals'
import { Router, RouterLink } from '@angular/router';
import { UserRegistration } from '@models/user';
@ -9,7 +13,7 @@ import { FormUtility } from '@utilities/form-utility';
@Component({
selector: 'app-signup',
imports: [FormsModule, Field, RouterLink],
imports: [FormsModule, Field, RouterLink, MatButtonModule, MatCardModule, MatFormFieldModule, MatInputModule],
templateUrl: './signup.html',
styleUrl: './signup.css',
})

View File

@ -1,3 +1,14 @@
.editor-sidebar-shell {
height: 100%;
min-height: 0;
}
.editor-sidebar-content {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.enabled:hover {
background-color: var(--color-gray-100) ;
}

View File

@ -1,10 +1,10 @@
<div
class="transition-all duration-300 ease-in-out overflow-hidden h-[calc(100vh-104px)]"
class="editor-sidebar-shell transition-all duration-300 ease-in-out overflow-hidden"
[class.w-14]="collapsed()"
[class.w-72]="!collapsed()"
[class.w-80]="!collapsed()"
>
<aside class="h-full w-full min-w-0 bg-white border-r border-gray-200 flex overflow-hidden">
<div class="flex flex-col h-full shrink-0">
<div class="flex flex-col h-full min-h-0 shrink-0">
<button (click)="collapse()" class="relative
w-6 h-6 rounded-full
@ -28,9 +28,9 @@
@if (!collapsed()) {
<!-- CONTENT -->
<div class="h-full min-w-0 bg-white flex-1 overflow-hidden">
<div class="h-full min-h-0 min-w-0 bg-white flex-1 overflow-hidden">
<div class="space-y-6 h-full min-w-0 overflow-hidden">
<div class="editor-sidebar-content h-full min-h-0 min-w-0 overflow-hidden">
@switch (open) {
@case ('flows') {
<app-group-holder title="Flows" icon="bi-lightning-charge-fill">

View File

@ -0,0 +1,146 @@
:host {
display: block;
height: 100%;
}
.blocks-list-loading {
display: flex;
align-items: center;
justify-content: center;
height: 8rem;
}
.blocks-list-root {
display: flex;
flex-direction: column;
width: 100%;
min-width: 0;
height: 100%;
overflow-x: hidden;
}
.blocks-list-search {
width: 100%;
padding: 8px 0;
}
.blocks-list-empty {
display: flex;
flex: 1;
flex-direction: column;
padding-top: 2.5rem;
margin-top: 2.5rem;
color: #94a3b8;
text-align: center;
}
.blocks-list-items {
display: flex;
flex: 1;
flex-direction: column;
gap: 8px;
width: 100%;
min-width: 0;
height: 100%;
max-height: calc(100vh - 320px);
overflow-x: hidden;
overflow-y: auto;
padding: 8px 0;
}
.blocks-list-card {
width: 100%;
min-width: 0;
overflow: hidden;
cursor: grab;
border: 1px solid #dbe7f5;
border-radius: 16px;
background:
radial-gradient(circle at top right, rgba(56, 189, 248, 0.12), transparent 34%),
linear-gradient(180deg, #ffffff 0%, #f7fbff 100%);
box-shadow:
0 12px 26px rgba(15, 23, 42, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.72);
transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease;
}
.blocks-list-card:active {
cursor: grabbing;
}
.blocks-list-card:hover {
transform: translateY(-2px);
border-color: #7dd3fc;
box-shadow: 0 18px 30px rgba(14, 116, 144, 0.14);
}
.blocks-list-card-header {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 14px 0;
}
.blocks-list-card-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
margin: 0;
border-radius: 12px;
color: #075985;
background: linear-gradient(180deg, #e0f2fe 0%, #bae6fd 100%);
}
.blocks-list-card-avatar .mat-icon {
width: 22px;
height: 22px;
font-size: 22px;
}
.blocks-list-card-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
.blocks-list-card-subtitle {
color: #475569;
font-size: 12px;
}
.blocks-list-card-content {
padding: 0 14px 8px;
}
.blocks-list-card-copy {
margin: 0;
font-size: 12px;
line-height: 1.45;
color: #64748b;
}
.blocks-list-card-actions {
padding: 0 14px 14px !important;
margin: 0;
}
.blocks-list-card-chip {
min-height: 28px;
font-size: 12px;
font-weight: 600;
color: #0369a1 !important;
background: rgba(224, 242, 254, 0.95) !important;
border: 1px solid rgba(125, 211, 252, 0.7);
}
.blocks-list-card-chip-interactive {
color: #b45309 !important;
background: rgba(254, 243, 199, 0.98) !important;
border-color: rgba(252, 211, 77, 0.72);
}

View File

@ -1,24 +1,15 @@
@if (loading()) {
<div class="flex items-center justify-center h-32">
<div class="spinner-border text-indigo-500" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<div class="blocks-list-loading">
<mat-spinner diameter="32"></mat-spinner>
</div>
} @else {
<div class="h-full flex flex-col w-full min-w-0 overflow-x-hidden">
<div class="w-full max-w-sm py-2 h-15">
<div class="relative flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
class="absolute w-5 h-5 top-2.5 left-2.5 text-slate-600">
<path fill-rule="evenodd"
d="M10.5 3.75a6.75 6.75 0 1 0 0 13.5 6.75 6.75 0 0 0 0-13.5ZM2.25 10.5a8.25 8.25 0 1 1 14.59 5.28l4.69 4.69a.75.75 0 1 1-1.06 1.06l-4.69-4.69A8.25 8.25 0 0 1 2.25 10.5Z"
clip-rule="evenodd" />
</svg>
<input
class="w-full bg-transparent placeholder:text-slate-400 text-slate-700 text-sm border border-slate-200 rounded-md pl-10 pr-3 py-2 transition duration-300 ease focus:outline-none focus:border-slate-400 hover:border-slate-300 shadow-sm focus:shadow"
placeholder="Search blocks..." [(ngModel)]="searchTerm" />
</div>
<div class="blocks-list-root">
<div class="blocks-list-search">
<mat-form-field appearance="outline">
<mat-label>Search blocks</mat-label>
<mat-icon matPrefix fontIcon="search"></mat-icon>
<input matInput placeholder="Search blocks..." [(ngModel)]="searchTerm" />
</mat-form-field>
</div>
<!--
@ -28,28 +19,34 @@
-->
@if (filteredBlocks().length === 0) {
<div class="text-gray-400 text-center mt-10 flex flex-col flex-1 pt-10">
<div class="blocks-list-empty">
No blocks found
</div>
} @else {
<div class="flex-1 overflow-y-auto overflow-x-hidden w-full min-w-0 h-full py-2 gap-2 flex flex-col items-stretch max-h-[calc(100vh-320px)]">
<div class="blocks-list-items">
@for (block of filteredBlocks(); track block.type) {
<div
class="border px-3 py-2 w-full min-w-0 bg-slate-50 border-slate-200 overflow-hidden cursor-grab active:cursor-grabbing"
<mat-card
class="blocks-list-card"
draggable="true"
(dragstart)="onDragStart($event, block)">
<div class="flex items-center justify-between gap-2 text-sm min-w-0">
<span class="font-medium text-slate-700 min-w-0 truncate">{{ block.type }}</span>
</div>
<p class="text-xs text-slate-500 mt-1">{{ block.description }}</p>
<span class="text-xs px-2 py-0.5 rounded"
[class.bg-amber-100]="block.userInteractive"
[class.text-amber-700]="block.userInteractive"
[class.bg-sky-100]="!block.userInteractive"
[class.text-sky-700]="!block.userInteractive">
<mat-card-header class="blocks-list-card-header">
<div matCardAvatar class="blocks-list-card-avatar">
<mat-icon [fontIcon]="block.userInteractive ? 'touch_app' : 'auto_awesome'"></mat-icon>
</div>
<mat-card-title class="blocks-list-card-title">{{ block.type }}</mat-card-title>
<mat-card-subtitle class="blocks-list-card-subtitle">
{{ block.userInteractive ? 'Interactive' : 'Automatic' }}
</span>
</div>
</mat-card-subtitle>
</mat-card-header>
<mat-card-content class="blocks-list-card-content">
<p class="blocks-list-card-copy">{{ block.description }}</p>
</mat-card-content>
<mat-card-actions class="blocks-list-card-actions" align="end">
<mat-chip class="blocks-list-card-chip" [class.blocks-list-card-chip-interactive]="block.userInteractive">
{{ block.userInteractive ? 'Human step' : 'Automated step' }}
</mat-chip>
</mat-card-actions>
</mat-card>
}
</div>
}

View File

@ -3,11 +3,17 @@ import { BlockType } from '@models/flow';
import { BlocksService } from '@services/blocks/blocks';
import { ListStateViewHolder, OrderViewState } from '@utilities/list-state-holder';
import { FormsModule } from '@angular/forms';
import { MatCardModule } from '@angular/material/card';
import { MatChipsModule } from '@angular/material/chips';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { BLOCK_TYPE_DRAG_MIME } from './block-drag';
@Component({
selector: 'app-blocks-list',
imports: [FormsModule],
imports: [FormsModule, MatCardModule, MatChipsModule, MatFormFieldModule, MatIconModule, MatInputModule, MatProgressSpinnerModule],
templateUrl: './blocks-list.html',
styleUrl: './blocks-list.css',
})

View File

@ -7,24 +7,22 @@
</div>
<!-- Modal -->
<div class="relative z-10 w-full max-w-md
rounded-lg bg-white shadow-xl
p-5 animate-fade-in">
<div class="relative z-10 w-full max-w-md rounded-2xl border border-slate-200 bg-white p-6 shadow-2xl">
<h3 class="text-lg font-semibold mb-3">
<h3 class="mb-3 text-lg font-semibold text-slate-900">
Confirm
</h3>
<p class="text-sm text-gray-700 mb-5">
<p class="mb-6 text-sm text-slate-600">
{{ this.state()!.message }}
</p>
<div class="flex justify-end gap-2">
<button class="btn btn-outline-secondary" (click)="close(false)">
<button mat-stroked-button (click)="close(false)">
Cancel
</button>
<button class="btn btn-primary" (click)="close(true)">
<button mat-flat-button (click)="close(true)">
OK
</button>
</div>
@ -32,4 +30,4 @@
</div>
}
}

View File

@ -1,9 +1,11 @@
import { Component, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { ConfirmDialogService } from '@services/dialogs/confirm-dialog';
@Component({
selector: 'app-confirm-dialog-host',
standalone: true,
imports: [MatButtonModule],
templateUrl: './confirm-dialog.html',
})
export class ConfirmDialogHostComponent {

View File

@ -52,16 +52,18 @@
</div>
@if (modelPickerOpen()) {
<select
id="assistant-model"
class="assistant-select"
[ngModel]="selectedModel()"
(ngModelChange)="selectModel($event)"
[disabled]="modelsLoading() || !models().length">
@for (model of models(); track model) {
<option [value]="model">{{ model }}</option>
}
</select>
<mat-form-field appearance="outline">
<mat-label>Model</mat-label>
<mat-select
id="assistant-model"
[ngModel]="selectedModel()"
(ngModelChange)="selectModel($event)"
[disabled]="modelsLoading() || !models().length">
@for (model of models(); track model) {
<mat-option [value]="model">{{ model }}</mat-option>
}
</mat-select>
</mat-form-field>
}
@if (modelsLoading()) {
@ -87,7 +89,7 @@
@if (quickPromptsOpen()) {
<div class="assistant-chip-grid">
@for (starter of starterPrompts; track starter) {
<button type="button" class="assistant-chip" (click)="useStarter(starter)">
<button type="button" mat-stroked-button class="assistant-chip" (click)="useStarter(starter)">
{{ starter }}
</button>
}
@ -130,17 +132,22 @@
</div>
<form class="assistant-composer" (ngSubmit)="submitPrompt()">
<textarea
class="assistant-input"
[ngModel]="prompt()"
(ngModelChange)="prompt.set($event)"
[disabled]="!selectedModel() || assistantBusy()"
name="assistantPrompt"
rows="4"
placeholder="Ask the assistant to create or refine a workflow"></textarea>
<mat-form-field appearance="outline">
<mat-label>Prompt</mat-label>
<textarea
matInput
class="assistant-input"
[ngModel]="prompt()"
(ngModelChange)="prompt.set($event)"
[disabled]="!selectedModel() || assistantBusy()"
name="assistantPrompt"
rows="4"
placeholder="Ask the assistant to create or refine a workflow"></textarea>
</mat-form-field>
<button
type="submit"
mat-flat-button
class="assistant-send"
[disabled]="!selectedModel() || assistantBusy() || !prompt().trim()">
Send

View File

@ -1,6 +1,10 @@
import { CommonModule } from '@angular/common';
import { Component, computed, inject, OnDestroy, OnInit, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { environment } from '@environment';
import {
AssistantCallPhase,
@ -18,7 +22,7 @@ import { finalize, take } from 'rxjs';
@Component({
selector: 'app-flow-assistant',
imports: [CommonModule, FormsModule],
imports: [CommonModule, FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule, MatSelectModule],
templateUrl: './flow-assistant.html',
styleUrl: './flow-assistant.css'
})

View File

@ -0,0 +1,103 @@
.flow-item-card {
width: 100%;
padding: 12px;
border: 1px solid #bfdbfe;
border-radius: 14px;
background:
radial-gradient(circle at top right, rgba(59, 130, 246, 0.12), transparent 32%),
linear-gradient(180deg, #ffffff 0%, #f3f8ff 100%);
box-shadow:
0 10px 24px rgba(15, 23, 42, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.72);
transition: transform 0.16s ease, border-color 0.16s ease, background-color 0.16s ease, box-shadow 0.16s ease;
}
.flow-item-card-clickable {
cursor: pointer;
}
.flow-item-card-clickable:hover {
transform: translateY(-3px);
border-color: #3b82f6;
background:
radial-gradient(circle at top right, rgba(96, 165, 250, 0.18), transparent 34%),
linear-gradient(180deg, #eff6ff 0%, #dbeafe 100%);
box-shadow: 0 18px 30px rgba(37, 99, 235, 0.16);
}
.flow-item-card-open {
border-color: #2563eb;
background:
radial-gradient(circle at top right, rgba(37, 99, 235, 0.2), transparent 34%),
linear-gradient(180deg, #dbeafe 0%, #bfdbfe 100%);
box-shadow: 0 20px 34px rgba(37, 99, 235, 0.18);
}
.flow-item-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.flow-item-meta {
flex: 1;
min-width: 0;
overflow: hidden;
}
.flow-item-title {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
font-size: 14px;
line-height: 1.3;
font-weight: 700;
color: #0f172a;
letter-spacing: 0.01em;
}
.flow-item-author {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: #334155;
margin-top: 3px;
}
.flow-item-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 10px;
font-size: 11px;
color: #475569;
padding-top: 8px;
border-top: 1px solid rgba(148, 163, 184, 0.18);
}
.flow-item-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.flow-item-visibility-icon {
color: #1e40af !important;
background: rgba(219, 234, 254, 0.88) !important;
border: 1px solid rgba(96, 165, 250, 0.45);
}
.flow-item-actions .mat-mdc-icon-button.mat-mdc-button-base {
color: #475569;
background: rgba(255, 255, 255, 0.58);
}
.flow-item-actions .mat-mdc-icon-button.mat-mdc-button-base:hover {
background: rgba(59, 130, 246, 0.14);
color: #1d4ed8;
}

View File

@ -1,40 +1,35 @@
<div class="w-full overflow-hidden text-left p-3 rounded-md border transition-all duration-150 select-none hover:-translate-y-0.5 hover:border-blue-300 hover:bg-blue-100 hover:shadow-md"
[class.cursor-pointer]="flow().id !== openedFlowId()"
[class.cursor-default]="flow().id === openedFlowId()"
[class.border-blue-300]="flow().id === openedFlowId()"
[class.bg-blue-50]="flow().id === openedFlowId()"
[class.border-slate-200]="flow().id !== openedFlowId()"
[class.bg-white]="flow().id !== openedFlowId()"
<mat-card class="flow-item-card"
[class.flow-item-card-clickable]="flow().id !== openedFlowId()"
[class.flow-item-card-open]="flow().id === openedFlowId()"
(click)="flow().id !== openedFlowId() && open()">
<div class="flex items-start justify-between gap-2">
<div class="flex-1 min-w-0 overflow-hidden">
<div class="text-sm font-medium text-slate-800 truncate">{{ flow().name }}</div>
<div class="text-xs text-slate-500 truncate">{{ flow().author }}</div>
<div class="flow-item-head">
<div class="flow-item-meta">
<div class="flow-item-title">{{ flow().name }}</div>
<div class="flow-item-author">{{ flow().author }}</div>
</div>
<span class="shrink-0 text-[10px] px-2 py-0.5 rounded-full border font-semibold"
[class.bg-indigo-100]="flow().visibility === 'PUBLIC'"
[class.text-indigo-700]="flow().visibility === 'PUBLIC'"
[class.border-indigo-200]="flow().visibility === 'PUBLIC'"
[class.bg-slate-100]="flow().visibility === 'PRIVATE'"
[class.text-slate-700]="flow().visibility === 'PRIVATE'"
[class.border-slate-200]="flow().visibility === 'PRIVATE'">
{{ flow().visibility }}
</span>
</div>
<div class="mt-2 text-[11px] text-slate-500 flex justify-between items-center gap-2">
<div class="flow-item-footer">
<span>{{ flow().createdAt | date:'dd/MM/yyyy' }}</span>
<div class="flex items-center gap-1.5 text-sm">
<button class="p-1 rounded enabled:hover:bg-blue-100 enabled:text-blue-600 disabled:text-blue-200" title="Clone"
(click)="$event.stopPropagation(); clone()" [disabled]="this.flow().visibility!=='PRIVATE'">
<i class="bi bi-files"></i>
<div class="flow-item-actions">
<button
mat-icon-button
[matTooltip]="flow().visibility === 'PRIVATE' ? 'Private flow' : 'Public flow'"
class="flow-item-visibility-icon"
(click)="$event.stopPropagation()"
disabled>
<mat-icon [fontIcon]="flow().visibility === 'PRIVATE' ? 'lock' : 'public'"></mat-icon>
</button>
<button class="p-1 rounded enabled:hover:bg-red-100 enabled:text-red-600 disabled:text-red-200" title="Delete"
<button mat-icon-button matTooltip="Clone flow"
(click)="$event.stopPropagation(); clone()" [disabled]="this.flow().visibility!=='PRIVATE'">
<mat-icon fontIcon="content_copy"></mat-icon>
</button>
<button mat-icon-button matTooltip="Delete flow"
(click)="$event.stopPropagation(); remove()" [disabled]="this.flow().visibility!=='PRIVATE'">
<i class="bi bi-trash"></i>
<mat-icon fontIcon="delete"></mat-icon>
</button>
</div>
</div>
</div>
</mat-card>

View File

@ -1,5 +1,9 @@
import { CommonModule } from '@angular/common';
import { Component, computed, inject, input, model } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { Flow } from '@models/flow';
import { ConfirmDialogService } from '@services/dialogs/confirm-dialog';
import { FlowsService } from '@services/flows/flows';
@ -7,7 +11,7 @@ import { EditorStateHolder } from '@stores/flow-editor';
@Component({
selector: 'app-flow-item',
imports: [CommonModule],
imports: [CommonModule, MatButtonModule, MatCardModule, MatIconModule, MatTooltipModule],
templateUrl: './flow-item.html',
styleUrl: './flow-item.css',
})

View File

@ -0,0 +1,75 @@
:host {
display: block;
height: 100%;
}
.flows-list-loading {
display: flex;
align-items: center;
justify-content: center;
height: 8rem;
}
.flows-list-root {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.flows-list-search {
width: 100%;
padding: 8px 0;
}
.flows-list-filter {
display: inline-flex;
align-self: center;
margin-bottom: 12px;
}
.flows-list-ordering {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 4.5rem;
padding: 8px 0;
}
.flows-list-empty {
display: flex;
flex: 1;
flex-direction: column;
padding-top: 2.5rem;
margin-top: 2.5rem;
color: #94a3b8;
text-align: center;
}
.flows-list-items {
flex: 1;
width: 100%;
min-height: 0;
max-height: calc(100vh - 380px);
overflow-y: auto;
box-sizing: border-box;
padding: 10px 0 20px !important;
background: transparent;
}
.flows-list-list-item {
height: auto !important;
padding: 0 !important;
margin-bottom: 10px;
overflow: visible !important;
}
.flows-list-list-item:last-child {
margin-bottom: 0;
}
:host ::ng-deep .flows-list-list-item .mdc-list-item__content,
:host ::ng-deep .flows-list-list-item .mat-mdc-list-item-unscoped-content {
overflow: visible !important;
}

View File

@ -1,58 +1,38 @@
@if (loading()) {
<div class="flex items-center justify-center h-32">
<div class="spinner-border text-indigo-500" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<div class="flows-list-loading">
<mat-spinner diameter="32"></mat-spinner>
</div>
} @else {
<div class="h-full flex flex-col">
<div class="w-full max-w-sm py-2 h-15">
<div class="relative flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
class="absolute w-5 h-5 top-2.5 left-2.5 text-slate-600">
<path fill-rule="evenodd"
d="M10.5 3.75a6.75 6.75 0 1 0 0 13.5 6.75 6.75 0 0 0 0-13.5ZM2.25 10.5a8.25 8.25 0 1 1 14.59 5.28l4.69 4.69a.75.75 0 1 1-1.06 1.06l-4.69-4.69A8.25 8.25 0 0 1 2.25 10.5Z"
clip-rule="evenodd" />
</svg>
<input
class="w-full bg-transparent placeholder:text-slate-400 text-slate-700 text-sm border border-slate-200 rounded-md pl-10 pr-3 py-2 transition duration-300 ease focus:outline-none focus:border-slate-400 hover:border-slate-300 shadow-sm focus:shadow"
placeholder="Search flows..." [(ngModel)]="searchTerm"/>
</div>
<div class="flows-list-root">
<div class="flows-list-search">
<mat-form-field appearance="outline">
<mat-label>Search flows</mat-label>
<mat-icon matPrefix fontIcon="search"></mat-icon>
<input matInput placeholder="Search flows..." [(ngModel)]="searchTerm" />
</mat-form-field>
</div>
<div class="btn-group w-full mb-3 h-8" role="group">
<mat-button-toggle-group class="flows-list-filter" [value]="filter()" (valueChange)="filter.set($event)">
<mat-button-toggle value="all">All</mat-button-toggle>
<mat-button-toggle value="PUBLIC">Public</mat-button-toggle>
<mat-button-toggle value="PRIVATE">Private</mat-button-toggle>
</mat-button-toggle-group>
<button type="button" class="btn btn-sm w-full transition" [class.btn-primary]="filter() === 'all'"
[class.btn-outline-primary]="filter() !== 'all'" (click)="filter.set('all')">
All
</button>
<button type="button" class="btn btn-sm w-full transition" [class.btn-primary]="filter() === 'PUBLIC'"
[class.btn-outline-primary]="filter() !== 'PUBLIC'" (click)="filter.set('PUBLIC')">
Public
</button>
<button type="button" class="btn btn-sm w-full transition" [class.btn-primary]="filter() === 'PRIVATE'"
[class.btn-outline-primary]="filter() !== 'PRIVATE'" (click)="filter.set('PRIVATE')">
Private
</button>
</div>
<div class="flex py-2 text-sm w-full items-center text-center justify-center h-18">
<div class="flows-list-ordering">
<app-ordering [orderFields]="orderFields" [orderView]="orderView" (orderChanged)="onOrderChanged($event)" ></app-ordering>
</div>
@if (filteredFlows().length === 0) {
<div class="text-gray-400 text-center mt-10 flex flex-col flex-1 pt-10">
<div class="flows-list-empty">
No flows found
</div>
} @else {
<div class="flex-1 overflow-y-auto w-full h-full py-2 gap-2 flex flex-col items-stretch max-h-[calc(100vh-380px)]">
<mat-list class="flows-list-items">
@for (flow of filteredFlows(); track flow.id) {
<app-flow-item [(detailOpenedId)]="detailOpenedId" [flow]="flow"></app-flow-item>
<mat-list-item class="flows-list-list-item">
<app-flow-item [(detailOpenedId)]="detailOpenedId" [flow]="flow"></app-flow-item>
</mat-list-item>
}
</div>
</mat-list>
}
</div>
}
}

View File

@ -3,6 +3,13 @@ import { Flow, FlowVisibility } from '@models/flow';
import { FlowsService } from '@services/flows/flows';
import { FlowItem } from './flow-item/flow-item';
import { FormsModule } from '@angular/forms';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatListModule } from '@angular/material/list';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { OrderEvent, OrderField, Ordering } from "@shared/ordering/ordering";
import { ListStateViewHolder, OrderViewState } from '@utilities/list-state-holder';
@ -10,7 +17,7 @@ type FlowFilter = FlowVisibility | 'all';
@Component({
selector: 'app-flows-list',
imports: [FlowItem, FormsModule, Ordering],
imports: [FlowItem, FormsModule, Ordering, MatButtonToggleModule, MatCardModule, MatFormFieldModule, MatIconModule, MatInputModule, MatListModule, MatProgressSpinnerModule],
templateUrl: './flows-list.html',
styleUrl: './flows-list.css',
})

View File

@ -0,0 +1,14 @@
:host {
display: flex;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
}
.group-holder-body {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-width: 0;
min-height: 0;
}

View File

@ -22,7 +22,8 @@
<!-- Body -->
<div
class="transition-all duration-300 px-2 pb-2 flex flex-col flex-1 min-w-0 overflow-hidden">
<div class="group-holder-body">
<ng-content></ng-content>
</div>
</div>
</div>

View File

@ -8,7 +8,7 @@
<h3 class="text-lg font-semibold text-slate-900">{{ currentState.title }}</h3>
<p class="mt-1 text-sm text-slate-500">Confirm the input as node output or edit it before sending.</p>
</div>
<button type="button" class="btn btn-outline-secondary btn-sm" (click)="cancel($event)">Close</button>
<button type="button" mat-stroked-button (click)="cancel($event)">Close</button>
</div>
<div class="grid flex-1 gap-4 overflow-y-auto px-5 py-4">
@ -23,27 +23,28 @@
</fieldset>
@if (editing()) {
<label class="block text-sm font-medium text-slate-700">
Edit Response
<mat-form-field appearance="outline">
<mat-label>Edit Response</mat-label>
<textarea
class="form-control mt-2 min-h-56"
matInput
class="min-h-56"
rows="10"
[ngModel]="draftValue"
[attr.data-autofocus]="'true'"
(ngModelChange)="setDraftValue($event)">
</textarea>
</label>
</mat-form-field>
}
</div>
<div class="flex justify-end gap-2 border-t border-slate-200 px-5 py-4">
<button type="button" class="btn btn-outline-secondary" (click)="cancel($event)">Cancel</button>
<button type="button" mat-stroked-button (click)="cancel($event)">Cancel</button>
@if (!editing()) {
<button type="button" class="btn btn-outline-primary" (click)="startEditing($event)">Edit Response</button>
<button type="button" class="btn btn-primary" (click)="confirmInput($event)">Confirm Input</button>
<button type="button" mat-stroked-button (click)="startEditing($event)">Edit Response</button>
<button type="button" mat-flat-button (click)="confirmInput($event)">Confirm Input</button>
} @else {
<button type="button" class="btn btn-outline-secondary" (click)="backToConfirm($event)">Back</button>
<button type="button" class="btn btn-primary" [disabled]="!canSendEditedOutput()" (click)="sendEditedOutput($event)">Send Output</button>
<button type="button" mat-stroked-button (click)="backToConfirm($event)">Back</button>
<button type="button" mat-flat-button [disabled]="!canSendEditedOutput()" (click)="sendEditedOutput($event)">Send Output</button>
}
</div>
</div>

View File

@ -1,5 +1,8 @@
import { Component, effect, ElementRef, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import {
HumanInteractionDialogResult,
HumanInteractionDialogService
@ -8,7 +11,7 @@ import {
@Component({
selector: 'app-human-interaction-dialog-host',
standalone: true,
imports: [FormsModule],
imports: [FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule],
templateUrl: './human-interaction-dialog.html'
})
export class HumanInteractionDialogHostComponent {

View File

@ -2,19 +2,15 @@
<div class="fixed inset-0 z-9999">
<div class="absolute inset-0 bg-black/50" (click)="cancel($event)"></div>
<div class="absolute inset-6 rounded-xl bg-white shadow-2xl border border-slate-200 p-5 flex flex-col gap-4 overflow-auto">
<div class="absolute inset-6 flex flex-col gap-4 overflow-auto rounded-2xl border border-slate-200 bg-white p-6 shadow-2xl">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-slate-900">{{ state()!.title }}</h3>
<button type="button" class="btn btn-sm btn-outline-secondary" (click)="cancel($event)">Close</button>
<button type="button" mat-stroked-button (click)="cancel($event)">Close</button>
</div>
<div class="grid gap-4">
@for (field of fields; track field.key) {
<label class="text-sm font-medium text-slate-700">
@if (field.type !== 'display') {
{{ field.label }}
}
<div class="text-sm font-medium text-slate-700">
@switch (field.type) {
@case ('display') {
<fieldset class="mt-1 border border-slate-200 rounded-md bg-slate-50 p-3">
@ -23,11 +19,12 @@
@if (field.copyable) {
<button
type="button"
class="btn btn-sm btn-outline-secondary py-0 px-2 text-[11px]"
title="Copy"
mat-stroked-button
class="!min-w-0 !px-2 !py-0 !text-[11px]"
matTooltip="Copy"
aria-label="Copy value"
(click)="copyFieldValue(field.key, $event)">
<i class="bi bi-copy"></i>
<mat-icon fontIcon="content_copy"></mat-icon>
</button>
}
</div>
@ -35,65 +32,72 @@
</fieldset>
}
@case ('textarea') {
<textarea
class="form-control mt-1"
[rows]="field.rows ?? 8"
[placeholder]="field.placeholder ?? ''"
[readonly]="field.readonly === true"
[attr.autofocus]="field.autofocus ? '' : null"
[attr.data-autofocus]="field.autofocus ? 'true' : null"
[ngModel]="draft[field.key]"
(ngModelChange)="setFieldValue(field.key, $event)">
</textarea>
<mat-form-field appearance="outline" class="mt-1">
<mat-label>{{ field.label }}</mat-label>
<textarea
matInput
[rows]="field.rows ?? 8"
[placeholder]="field.placeholder ?? ''"
[readonly]="field.readonly === true"
[attr.autofocus]="field.autofocus ? '' : null"
[attr.data-autofocus]="field.autofocus ? 'true' : null"
[ngModel]="draft[field.key]"
(ngModelChange)="setFieldValue(field.key, $event)">
</textarea>
</mat-form-field>
}
@case ('select') {
<select
class="form-select mt-1"
[disabled]="field.readonly === true"
[attr.autofocus]="field.autofocus ? '' : null"
[attr.data-autofocus]="field.autofocus ? 'true' : null"
[ngModel]="draft[field.key]"
(ngModelChange)="setFieldValue(field.key, $event)">
@for (option of field.options ?? []; track option.value) {
<option [ngValue]="option.value">{{ option.label }}</option>
}
</select>
}
@case ('checkbox') {
<div class="mt-2">
<input
class="form-check-input"
type="checkbox"
<mat-form-field appearance="outline" class="mt-1">
<mat-label>{{ field.label }}</mat-label>
<mat-select
[disabled]="field.readonly === true"
[attr.autofocus]="field.autofocus ? '' : null"
[attr.data-autofocus]="field.autofocus ? 'true' : null"
[ngModel]="draft[field.key]"
(ngModelChange)="setFieldValue(field.key, $event)" />
(ngModelChange)="setFieldValue(field.key, $event)">
@for (option of field.options ?? []; track option.value) {
<mat-option [value]="option.value">{{ option.label }}</mat-option>
}
</mat-select>
</mat-form-field>
}
@case ('checkbox') {
<div class="mt-2">
<mat-checkbox
[disabled]="field.readonly === true"
[attr.data-autofocus]="field.autofocus ? 'true' : null"
[ngModel]="draft[field.key]"
(ngModelChange)="setFieldValue(field.key, $event)">
{{ field.label }}
</mat-checkbox>
</div>
}
@default {
<input
class="form-control mt-1"
type="text"
[placeholder]="field.placeholder ?? ''"
[readonly]="field.readonly === true"
[attr.autofocus]="field.autofocus ? '' : null"
[attr.data-autofocus]="field.autofocus ? 'true' : null"
[ngModel]="draft[field.key]"
(ngModelChange)="setFieldValue(field.key, $event)" />
<mat-form-field appearance="outline" class="mt-1">
<mat-label>{{ field.label }}</mat-label>
<input
matInput
type="text"
[placeholder]="field.placeholder ?? ''"
[readonly]="field.readonly === true"
[attr.autofocus]="field.autofocus ? '' : null"
[attr.data-autofocus]="field.autofocus ? 'true' : null"
[ngModel]="draft[field.key]"
(ngModelChange)="setFieldValue(field.key, $event)" />
</mat-form-field>
}
}
@if (field.tip) {
<small class="text-red-600 mt-1 block">{{ field.tip }}</small>
}
</label>
</div>
}
</div>
<div class="mt-auto flex justify-end gap-2">
<button type="button" class="btn btn-outline-secondary" (click)="cancel($event)">Cancel</button>
<button type="button" class="btn btn-primary" (click)="save($event)">Save</button>
<button type="button" mat-stroked-button (click)="cancel($event)">Cancel</button>
<button type="button" mat-flat-button (click)="save($event)">Save</button>
</div>
</div>
</div>

View File

@ -1,5 +1,12 @@
import { Component, effect, ElementRef, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatTooltipModule } from '@angular/material/tooltip';
import {
NodeSettingField,
NodeSettingsDialogService,
@ -9,7 +16,7 @@ import {
@Component({
selector: 'app-node-settings-dialog-host',
standalone: true,
imports: [FormsModule],
imports: [FormsModule, MatButtonModule, MatCheckboxModule, MatFormFieldModule, MatIconModule, MatInputModule, MatSelectModule, MatTooltipModule],
templateUrl: './node-settings-dialog.html'
})
export class NodeSettingsDialogHostComponent {

View File

@ -2,3 +2,49 @@
display: block;
width: 100%;
}
:host {
display: block;
width: 100%;
}
.ordering-root {
width: 100%;
}
.ordering-field {
width: 100%;
}
.ordering-suffix {
display: flex;
align-items: center;
gap: 4px;
margin-left: 10px;
margin-right: 6px;
padding-left: 4px;
}
.ordering-dir-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: 0;
border-radius: 8px;
background: transparent;
color: #64748b;
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
}
.ordering-dir-btn:hover {
background: #eef2ff;
color: #1d4ed8;
}
.ordering-dir-btn-active {
background: #dbeafe;
color: #1d4ed8;
}

View File

@ -1,42 +1,34 @@
<div class="flex items-center w-full justify-between gap-2">
<div class="form-floating flex-1">
<select id="orderSelect"
class="form-select form-select-sm text-sm w-full"
[(ngModel)]="orderBy"
placeholder="Order By"
>
@for (opt of orderFields(); track opt.field) {
<option [ngValue]="opt.field">
{{ opt.label || opt.field }}
</option>
}
</select>
<label for="orderSelect">Order by</label>
</div>
<!-- direzione -->
<div class="btn-group">
<button
type="button"
class="btn btn-sm px-1 w-6 h-14 flex items-center justify-center"
[class.btn-primary]="orderDir() === 'asc'"
[class.btn-outline-primary]="orderDir() !== 'asc'"
(click)="orderDir.set('asc')"
title="Ascending"
>
↑
</button>
<div class="ordering-root">
<mat-form-field appearance="outline" class="ordering-field">
<mat-label>Order by</mat-label>
<mat-select [(ngModel)]="orderBy">
@for (opt of orderFields(); track opt.field) {
<mat-option [value]="opt.field">{{ opt.label || opt.field }}</mat-option>
}
</mat-select>
<button
type="button"
class="btn btn-sm px-1 w-6 h-14 flex items-center justify-center"
[class.btn-primary]="orderDir() === 'desc'"
[class.btn-outline-primary]="orderDir() !== 'desc'"
(click)="orderDir.set('desc')"
title="Descending"
>
↓
</button>
</div>
<div matSuffix class="ordering-suffix">
<button
type="button"
class="ordering-dir-btn"
[class.ordering-dir-btn-active]="orderDir() === 'asc'"
(mousedown)="setDirection('asc', $event)"
(click)="setDirection('asc', $event)"
aria-label="Ascending"
title="Ascending">
<mat-icon fontIcon="north"></mat-icon>
</button>
<button
type="button"
class="ordering-dir-btn"
[class.ordering-dir-btn-active]="orderDir() === 'desc'"
(mousedown)="setDirection('desc', $event)"
(click)="setDirection('desc', $event)"
aria-label="Descending"
title="Descending">
<mat-icon fontIcon="south"></mat-icon>
</button>
</div>
</mat-form-field>
</div>

View File

@ -1,5 +1,8 @@
import { Component, effect, Input, input, model, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatSelectModule } from '@angular/material/select';
import { OrderViewState } from '@utilities/list-state-holder';
export type OrderEvent = { orderBy: string | null; orderDir: orderDirType };
@ -9,7 +12,7 @@ export type OrderField = { field: string; label?: string };
@Component({
selector: 'app-ordering',
imports: [FormsModule],
imports: [FormsModule, MatFormFieldModule, MatIconModule, MatSelectModule],
templateUrl: './ordering.html',
styleUrl: './ordering.css',
})
@ -36,6 +39,12 @@ export class Ordering {
}
}
setDirection(direction: orderDirType, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
this.orderDir.set(direction);
}
orderChanged = output<OrderEvent>();
}

View File

@ -27,16 +27,20 @@
}
<div class="mt-3 flex items-start gap-2">
<input
class="form-control form-control-sm"
[type]="isAuthorizationVisible(requirement.key) ? 'text' : 'password'"
[readonly]="readOnly()"
[ngModel]="authorizationValue(requirement.key)"
(ngModelChange)="onAuthorizationValueChange(requirement, $event)" />
<mat-form-field appearance="outline" class="flex-1">
<mat-label>{{ requirement.fieldName || 'Authorization' }}</mat-label>
<input
matInput
[type]="isAuthorizationVisible(requirement.key) ? 'text' : 'password'"
[readonly]="readOnly()"
[ngModel]="authorizationValue(requirement.key)"
(ngModelChange)="onAuthorizationValueChange(requirement, $event)" />
</mat-form-field>
<button
type="button"
class="btn btn-outline-secondary btn-sm shrink-0"
mat-stroked-button
class="shrink-0"
[disabled]="readOnly()"
(click)="toggleAuthorizationVisibility(requirement.key, $event)">
{{ isAuthorizationVisible(requirement.key) ? 'Hide' : 'Show' }}
@ -44,7 +48,8 @@
<button
type="button"
class="btn btn-primary btn-sm shrink-0"
mat-flat-button
class="shrink-0"
[disabled]="!canSubmitAuthorization(requirement.key)"
(click)="submitAuthorization(requirement, $event)">
Save
@ -77,19 +82,18 @@
<div class="text-[10px] text-slate-500 mb-2">Type: {{ executionInput.type }}</div>
@if (isFileInput(executionInput)) {
<input
type="file"
class="form-control form-control-sm"
[disabled]="readOnly()"
(change)="onFileInputChange(executionInput, $event)" />
<input type="file" class="block w-full text-sm text-slate-700" [disabled]="readOnly()" (change)="onFileInputChange(executionInput, $event)" />
} @else {
<textarea
class="form-control form-control-sm"
rows="4"
[readonly]="readOnly()"
[ngModel]="executionInput.value"
(ngModelChange)="onTextInputChange(executionInput, $event)">
</textarea>
<mat-form-field appearance="outline">
<mat-label>{{ executionInput.subtitle }}</mat-label>
<textarea
matInput
rows="4"
[readonly]="readOnly()"
[ngModel]="executionInput.value"
(ngModelChange)="onTextInputChange(executionInput, $event)">
</textarea>
</mat-form-field>
}
@if (isInputSaving(executionInput.key)) {

View File

@ -1,6 +1,9 @@
import { CommonModule } from '@angular/common';
import { Component, input, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { TaskExecutionAuthorizationRequirement } from '@models/task-execution';
export type EditableExecutionInput = {
@ -15,7 +18,7 @@ export type EditableExecutionInput = {
@Component({
selector: 'app-task-execution-inputs-panel',
imports: [CommonModule, FormsModule],
imports: [CommonModule, FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule],
templateUrl: './task-execution-inputs-panel.html'
})
export class TaskExecutionInputsPanelComponent {

View File

@ -10,10 +10,11 @@
<button
type="button"
class="self-stretch h-full w-10 rounded-md border border-emerald-300 bg-emerald-50 text-emerald-600 hover:bg-emerald-100 disabled:opacity-40 disabled:cursor-not-allowed"
matTooltip="Start execution"
aria-label="Start execution"
[disabled]="!canStartExecution() || startInProgress()"
(click)="startExecution()">
<i class="bi bi-play-fill text-lg leading-none"></i>
<mat-icon fontIcon="play_arrow"></mat-icon>
</button>
</div>
</div>
@ -113,7 +114,8 @@
@if (output.isLong) {
<button
type="button"
class="mt-3 text-xs font-semibold text-blue-600 hover:text-blue-700"
mat-stroked-button
class="mt-3"
(click)="openOutputPreview(output, $event)">
View full output
</button>
@ -137,7 +139,7 @@
<h3 class="text-lg font-semibold text-slate-900">{{ outputModal.title }}</h3>
<p class="mt-1 break-all text-xs text-slate-500">{{ outputModal.subtitle }}</p>
</div>
<button type="button" class="btn btn-outline-secondary btn-sm" (click)="closeOutputPreview($event)">Close</button>
<button type="button" mat-stroked-button (click)="closeOutputPreview($event)">Close</button>
</div>
<div class="min-h-0 flex-1 overflow-auto px-5 py-4">
<pre class="execution-output-modal">{{ outputModal.value }}</pre>

View File

@ -1,5 +1,8 @@
import { CommonModule } from '@angular/common';
import { Component, computed, effect, inject, input, OnDestroy, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { FlowBlockConnection, FlowData } from '@models/flow';
import {
getExecutionStatusGroup,
@ -25,7 +28,7 @@ type ExecutionOutputEntry = {
@Component({
selector: 'app-task-execution-viewer',
imports: [CommonModule, ReteEditor, TaskExecutionInputsPanelComponent],
imports: [CommonModule, ReteEditor, TaskExecutionInputsPanelComponent, MatButtonModule, MatIconModule, MatTooltipModule],
templateUrl: './task-execution-viewer.html',
styleUrl: './task-execution-viewer.css',
})

View File

@ -2,3 +2,137 @@
display: block;
height: 100%;
}
.tasks-list-shell {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 8px;
}
.tasks-list-search,
.tasks-list-filter,
.tasks-list-ordering {
padding: 16px 12px 0;
}
.tasks-list-filter {
display: flex;
justify-content: center;
}
.tasks-list-filter-group {
display: inline-flex;
width: auto;
max-width: 100%;
}
.tasks-list-ordering {
padding-bottom: 16px;
}
.tasks-list-items {
flex: 1;
overflow-y: auto;
padding: 10px 8px;
}
.tasks-list-list {
padding: 0 !important;
background: transparent;
}
.tasks-list-list-item {
height: auto !important;
padding: 0 !important;
margin-bottom: 10px;
overflow: visible !important;
}
:host ::ng-deep .tasks-list-list-item .mdc-list-item__content,
:host ::ng-deep .tasks-list-list-item .mat-mdc-list-item-unscoped-content {
overflow: visible !important;
}
.tasks-list-empty {
padding: 16px 8px;
font-size: 12px;
color: #64748b;
text-align: center;
}
.tasks-list-item {
width: 100%;
padding: 12px;
border: 1px solid #cbd5e1;
border-radius: 14px;
background:
radial-gradient(circle at top right, rgba(16, 185, 129, 0.1), transparent 32%),
linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
box-shadow:
0 10px 24px rgba(15, 23, 42, 0.07),
inset 0 1px 0 rgba(255, 255, 255, 0.72);
text-align: left;
cursor: pointer;
transition: transform 0.15s ease, border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
}
.tasks-list-item:hover {
transform: translateY(-3px);
border-color: #60a5fa;
background:
radial-gradient(circle at top right, rgba(96, 165, 250, 0.16), transparent 34%),
linear-gradient(180deg, #f8fbff 0%, #dbeafe 100%);
box-shadow: 0 18px 28px rgba(37, 99, 235, 0.14);
}
.tasks-list-item-meta {
min-width: 0;
}
.tasks-list-item-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
font-weight: 700;
color: #1e293b;
}
.tasks-list-item-subtitle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: #64748b;
}
.tasks-list-status {
padding: 3px 9px;
border-radius: 999px;
border-width: 1px;
font-size: 10px;
font-weight: 600;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.52);
}
.tasks-list-item-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 10px;
font-size: 11px;
color: #64748b;
padding-top: 8px;
border-top: 1px solid rgba(148, 163, 184, 0.18);
}
.tasks-list-item-actions {
display: flex;
align-items: center;
gap: 8px;
}

View File

@ -1,42 +1,23 @@
<aside class="h-full bg-white border border-slate-200 rounded-md flex flex-col overflow-hidden">
<aside class="tasks-list-shell">
<div class="px-3 pt-4">
<div class="relative flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
class="absolute w-5 h-5 top-2.5 left-2.5 text-slate-600">
<path fill-rule="evenodd"
d="M10.5 3.75a6.75 6.75 0 1 0 0 13.5 6.75 6.75 0 0 0 0-13.5ZM2.25 10.5a8.25 8.25 0 1 1 14.59 5.28l4.69 4.69a.75.75 0 1 1-1.06 1.06l-4.69-4.69A8.25 8.25 0 0 1 2.25 10.5Z"
clip-rule="evenodd" />
</svg>
<input
class="w-full bg-transparent placeholder:text-slate-400 text-slate-700 text-sm border border-slate-200 rounded-md pl-10 pr-3 py-2 transition duration-300 ease focus:outline-none focus:border-slate-400 hover:border-slate-300 shadow-sm focus:shadow"
placeholder="Search executions..."
[(ngModel)]="searchTerm" />
</div>
<div class="tasks-list-search">
<mat-form-field appearance="outline">
<mat-label>Search executions</mat-label>
<mat-icon matPrefix fontIcon="search"></mat-icon>
<input matInput placeholder="Search executions..." [(ngModel)]="searchTerm" />
</mat-form-field>
</div>
<div class="px-3 pt-4">
<div class="btn-group w-full h-8" role="group">
<button type="button" class="btn btn-sm w-full transition"
[class.btn-primary]="filter() === 'all'"
[class.btn-outline-primary]="filter() !== 'all'"
(click)="filter.set('all')">All</button>
<button type="button" class="btn btn-sm w-full transition"
[class.btn-primary]="filter() === 'INIT'"
[class.btn-outline-primary]="filter() !== 'INIT'"
(click)="filter.set('INIT')">Init</button>
<button type="button" class="btn btn-sm w-full transition"
[class.btn-primary]="filter() === 'RUNNING'"
[class.btn-outline-primary]="filter() !== 'RUNNING'"
(click)="filter.set('RUNNING')">Running</button>
<button type="button" class="btn btn-sm w-full transition"
[class.btn-primary]="filter() === 'FINAL'"
[class.btn-outline-primary]="filter() !== 'FINAL'"
(click)="filter.set('FINAL')">Final</button>
</div>
<div class="tasks-list-filter">
<mat-button-toggle-group class="tasks-list-filter-group" [value]="filter()" (valueChange)="filter.set($event)">
<mat-button-toggle value="all">All</mat-button-toggle>
<mat-button-toggle value="INIT">Init</mat-button-toggle>
<mat-button-toggle value="RUNNING">Running</mat-button-toggle>
<mat-button-toggle value="FINAL">Final</mat-button-toggle>
</mat-button-toggle-group>
</div>
<div class="px-3 py-4">
<div class="tasks-list-ordering">
<app-ordering
[orderFields]="orderFields"
[orderView]="orderView"
@ -44,45 +25,50 @@
</app-ordering>
</div>
<div class="flex-1 overflow-y-auto p-2 space-y-2">
<div class="tasks-list-items">
@if (!filteredExecutions().length) {
<div class="text-xs text-slate-500 px-2 py-4 text-center">No executions available</div>
<div class="tasks-list-empty">No executions available</div>
} @else {
<mat-list class="tasks-list-list">
@for (execution of filteredExecutions(); track execution.id) {
<div
class="w-full text-left p-3 rounded-md border transition-all duration-150 hover:-translate-y-0.5 hover:border-blue-300 hover:bg-blue-100 hover:shadow-md"
<mat-list-item class="tasks-list-list-item">
<mat-card
class="tasks-list-item"
[class.border-blue-300]="execution.id === selectedExecutionId()"
[class.bg-blue-50]="execution.id === selectedExecutionId()"
[class.border-slate-200]="execution.id !== selectedExecutionId()"
[class.bg-white]="execution.id !== selectedExecutionId()"
(click)="selectExecution(execution.id)">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<div class="text-sm font-medium text-slate-800 truncate">{{ execution.title }}</div>
<div class="text-xs text-slate-500 truncate">{{ execution.flowName }}</div>
<div class="tasks-list-item-meta">
<div class="tasks-list-item-title">{{ execution.title }}</div>
<div class="tasks-list-item-subtitle">{{ execution.flowName }}</div>
</div>
<span class="text-[10px] px-2 py-0.5 rounded-full border font-semibold"
<span class="tasks-list-status"
[ngClass]="statusBadgeClass(execution.status)">
{{ execution.status }}
</span>
</div>
<div class="mt-2 text-[11px] text-slate-500 flex justify-between items-center gap-2">
<div class="tasks-list-item-footer">
<span>{{ execution.startedAt }}</span>
<div class="flex items-center gap-2">
<div class="tasks-list-item-actions">
<span>{{ execution.duration || '-' }}</span>
<button
type="button"
class="p-1 rounded enabled:hover:bg-red-100 enabled:text-red-600 disabled:text-red-200"
title="Delete"
mat-icon-button
class="!h-8 !w-8 enabled:!text-red-600 disabled:!text-red-200"
matTooltip="Delete execution"
aria-label="Delete execution"
[disabled]="isDeleteDisabled(execution.status)"
(click)="requestDeleteExecution(execution.id, $event)">
<i class="bi bi-trash"></i>
<mat-icon fontIcon="delete"></mat-icon>
</button>
</div>
</div>
</div>
</mat-card>
</mat-list-item>
}
</mat-list>
}
</div>
</aside>

View File

@ -1,6 +1,14 @@
import { CommonModule } from '@angular/common';
import { Component, computed, input, model, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatListModule } from '@angular/material/list';
import { MatTooltipModule } from '@angular/material/tooltip';
import { getExecutionStatusGroup, TaskExecutionStatus, TaskExecutionStatusGroup } from '@models/task-execution';
import { OrderEvent, OrderField, Ordering, orderDirType } from '@shared/ordering/ordering';
import { OrderViewState } from '@utilities/list-state-holder';
@ -18,7 +26,7 @@ export type TaskExecutionListItem = {
@Component({
selector: 'app-tasks-executions-list',
imports: [CommonModule, FormsModule, Ordering],
imports: [CommonModule, FormsModule, Ordering, MatButtonModule, MatButtonToggleModule, MatCardModule, MatFormFieldModule, MatIconModule, MatInputModule, MatListModule, MatTooltipModule],
templateUrl: './tasks-executions-list.html',
styleUrl: './tasks-executions-list.css',
})

View File

@ -1,5 +1,110 @@
.title-toolbar-input {
width: min(100%, 48vw);
.title-toolbar-shell {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 8px 16px;
background: #ffffff;
border-bottom: 1px solid #e5e7eb;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.title-toolbar-main {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.title-toolbar-title-wrap {
flex: 1;
min-width: 0;
}
.title-toolbar-title-row {
display: flex;
align-items: center;
gap: 2px;
min-width: 0;
width: fit-content;
max-width: min(100%, 32rem);
}
.title-toolbar-field {
width: min(100%, 32rem);
margin-bottom: -1.25em;
}
.title-toolbar-title-display {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 1.1rem;
font-weight: 700;
color: #0f172a;
}
.title-toolbar-edit-button {
display: inline-flex !important;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 30px !important;
height: 30px !important;
padding: 0 !important;
margin-left: 5px;
border-radius: 8px !important;
color: #1d4ed8 !important;
background: rgba(219, 234, 254, 0.95) !important;
border: 1px solid rgba(96, 165, 250, 0.55);
}
.title-toolbar-edit-button:hover {
background: rgba(191, 219, 254, 0.98) !important;
}
.title-toolbar-edit-button .mat-icon {
width: 18px;
height: 18px;
font-size: 18px;
}
.title-toolbar-pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border: 1px solid;
border-radius: 999px;
font-size: 12px;
font-weight: 500;
}
.title-toolbar-pill-warning {
border-color: #eab308;
color: #ca8a04;
}
.title-toolbar-pill-info {
border-color: #3b82f6;
color: #2563eb;
}
.title-toolbar-execute {
border-color: #10b981 !important;
background: #ecfdf5 !important;
color: #047857 !important;
border-radius: 999px !important;
}
.title-toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.title-toolbar-spinner {
@ -39,3 +144,26 @@
.save-snackbar-error {
background: #b91c1c;
}
.title-toolbar-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 16px;
padding: 8px 16px;
background: #ffffff;
border-bottom: 1px solid #e5e7eb;
font-size: 12px;
color: #475569;
}
.title-toolbar-meta-label {
color: #94a3b8;
}
.title-toolbar-description {
max-width: 420px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@ -1,35 +1,49 @@
<div class="flex items-center justify-between gap-4
px-4 py-2
bg-white border-b border-gray-200
shadow-sm">
<div class="title-toolbar-shell">
<!-- Title -->
<div class="flex min-w-0 flex-1 items-center gap-2">
<i class="bi bi-pencil-square shrink-0 text-indigo-500"></i>
<h2 class="m-0 min-w-0 flex-1 text-sm font-semibold text-gray-800">
<input type="text" class="title-toolbar-input border-0 text-sm font-semibold text-gray-800
focus:ring-0 p-0 m-0 box-border
min-w-0
bg-transparent"
[value]="title()"
(blur)="changeTitle(titleInput.value)"
(keydown.enter)="changeTitle(titleInput.value); $event.target.blur()"
maxlength="20"
size="20"
#titleInput
/>
</h2>
<div class="title-toolbar-main">
<div class="title-toolbar-title-wrap">
@if (editingTitle()) {
<mat-form-field appearance="outline" class="title-toolbar-field">
<mat-label>Flow title</mat-label>
<input
matInput
[value]="draftTitle()"
(input)="draftTitle.set(titleInput.value)"
(blur)="changeTitle(titleInput.value)"
(keydown.enter)="changeTitle(titleInput.value); $event.preventDefault(); $event.target.blur()"
(keydown.escape)="cancelEditingTitle()"
maxlength="30"
#titleInput />
<mat-hint align="end">{{ draftTitle().length }}/30</mat-hint>
</mat-form-field>
} @else {
<div class="title-toolbar-title-row">
<div class="title-toolbar-title-display" [title]="title()">{{ title() }}</div>
@if (flow()) {
<button
type="button"
mat-icon-button
class="title-toolbar-edit-button"
matTooltip="Edit flow title"
(click)="startEditingTitle()">
<mat-icon fontIcon="edit"></mat-icon>
</button>
}
</div>
}
</div>
@if (notSaved()) {
<span class="inline-flex shrink-0 items-center gap-x-1.5 rounded-full border border-yellow-500 px-3 py-1.5 text-xs font-medium text-yellow-500">not saved</span>
<span class="title-toolbar-pill title-toolbar-pill-warning">Not saved</span>
} @else if (blockSyncInProgress()) {
<span class="inline-flex shrink-0 items-center gap-2 rounded-full border border-blue-500 px-3 py-1.5 text-xs font-medium text-blue-600">
<span class="title-toolbar-pill title-toolbar-pill-info">
<span class="title-toolbar-spinner title-toolbar-spinner--blue" aria-hidden="true"></span>
<span>Updating blocks...</span>
</span>
} @else if (canExecute()) {
<button
type="button"
class="inline-flex shrink-0 items-center gap-2 rounded-full border border-emerald-500 bg-emerald-50 px-3 py-1.5 text-xs font-medium text-emerald-700 transition enabled:hover:bg-emerald-100 disabled:cursor-default disabled:opacity-60"
mat-stroked-button
class="title-toolbar-execute"
[disabled]="executeLoading()"
(click)="execute()">
@if (executeLoading()) {
@ -40,29 +54,26 @@
}
</div>
<!-- Actions -->
<div class="flex shrink-0 items-center gap-2" >
<button tabindex="-1"
class="border-0
flex items-center gap-1
enabled:hover:bg-green-200 enabled:text-green-600 disabled:text-green-300
p-1"
title="Save" [disabled]="!canSave()" (click)="save()">
<i class="bi bi-save"></i>
<div class="title-toolbar-actions" >
<button
type="button"
mat-flat-button
tabindex="-1"
[disabled]="!canSave()"
(click)="save()">
Save
</button>
</div>
</div>
@if (flow()) {
<div class="px-4 py-2 bg-white border-b border-gray-200 text-xs text-slate-600 flex flex-wrap items-center gap-x-4 gap-y-1">
<span><span class="text-slate-400">Author:</span> {{ flow()!.author }}</span>
<span><span class="text-slate-400">Visibility:</span> {{ flow()!.visibility }}</span>
<span><span class="text-slate-400">Created:</span> {{ flow()!.createdAt | date:'dd/MM/yyyy HH:mm' }}</span>
<span><span class="text-slate-400">Updated:</span> {{ flow()!.updatedAt | date:'dd/MM/yyyy HH:mm' }}</span>
<div class="title-toolbar-meta">
<span><span class="title-toolbar-meta-label">Author:</span> {{ flow()!.author }}</span>
<span><span class="title-toolbar-meta-label">Visibility:</span> {{ flow()!.visibility }}</span>
<span><span class="title-toolbar-meta-label">Created:</span> {{ flow()!.createdAt | date:'dd/MM/yyyy HH:mm' }}</span>
<span><span class="title-toolbar-meta-label">Updated:</span> {{ flow()!.updatedAt | date:'dd/MM/yyyy HH:mm' }}</span>
@if (flow()!.description) {
<span class="truncate max-w-[420px]"><span class="text-slate-400">Description:</span> {{ flow()!.description }}</span>
<span class="title-toolbar-description"><span class="title-toolbar-meta-label">Description:</span> {{ flow()!.description }}</span>
}
</div>
}

View File

@ -1,5 +1,11 @@
import { CommonModule } from '@angular/common';
import { Component, computed, ElementRef, inject, signal, ViewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatTooltipModule } from '@angular/material/tooltip';
import { Router } from '@angular/router';
import { BlocksService } from '@services/blocks/blocks';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
@ -8,7 +14,7 @@ import { EditorStateHolder } from '@stores/flow-editor';
@Component({
selector: 'app-title-toolbar',
imports: [CommonModule],
imports: [CommonModule, FormsModule, MatButtonModule, MatFormFieldModule, MatIconModule, MatInputModule, MatTooltipModule],
templateUrl: './title-toolbar.html',
styleUrl: './title-toolbar.css',
})
@ -37,17 +43,41 @@ export class TitleToolbar {
executeLoading = signal(false);
snackbarMessage = signal<string | null>(null);
snackbarType = signal<'success' | 'error'>('success');
editingTitle = signal(false);
draftTitle = signal('');
startEditingTitle() {
const flow = this.flow();
if (!flow) return;
this.draftTitle.set(flow.name);
this.editingTitle.set(true);
queueMicrotask(() => {
this.myInputRef?.nativeElement?.focus();
this.myInputRef?.nativeElement?.select();
});
}
cancelEditingTitle() {
this.editingTitle.set(false);
this.draftTitle.set(this.title());
}
changeTitle(value: string) {
const trimmed = value.trim();
if (value === this.title()) return;
if (trimmed === this.title()) {
this.editingTitle.set(false);
return;
}
if (trimmed.length < 4) {
this.draftTitle.set(this.title());
this.myInputRef.nativeElement.value = this.title();
this.editingTitle.set(false);
return;
}
console.log('Updating flow title to:', trimmed);
this.editorState.updateFlowTitle(trimmed );
this.draftTitle.set(trimmed);
this.editingTitle.set(false);
}
save() {

View File

@ -1,5 +1,7 @@
/* You can add global styles to this file, and also import other style files */
@import '@fontsource/material-icons-outlined';
@import '@angular/material/prebuilt-themes/azure-blue.css';
@import 'tailwindcss';
@theme {
@ -31,6 +33,35 @@
body {
@apply bg-gray-50 dark:bg-gray-900;
color: #0f172a;
font-family: var(--font-body);
}
.mat-mdc-form-field {
width: 100%;
}
.mat-mdc-dialog-surface,
.mat-mdc-card {
border-radius: 16px;
}
.material-icons,
.material-icons-outlined {
font-family: 'Material Icons Outlined' !important;
font-weight: normal;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
font-feature-settings: 'liga';
}
.container-centered {
@ -86,4 +117,4 @@ body {
}
.inner-nav {
@apply flex flex-wrap justify-center gap-4 items-center max-w-7xl;
}
}