diff --git a/src/app/interceptors/auth-token.interceptor.ts b/src/app/interceptors/auth-token.interceptor.ts index 731d6e1..7a64dfe 100644 --- a/src/app/interceptors/auth-token.interceptor.ts +++ b/src/app/interceptors/auth-token.interceptor.ts @@ -8,6 +8,7 @@ let lastServiceErrorNotificationAt = 0; export const authTokenInterceptor: HttpInterceptorFn = (req, next) => { const token = getToken(); const requestPath = req.url.split('?')[0]; + const isChangePasswordEndpoint = requestPath.endsWith('/auth/change-password'); const isAuthEndpoint = requestPath.endsWith('/auth/login') || requestPath.endsWith('/auth/register'); @@ -40,6 +41,7 @@ export const authTokenInterceptor: HttpInterceptorFn = (req, next) => { if ( hasToken && !isAuthEndpoint && + !isChangePasswordEndpoint && error instanceof HttpErrorResponse && error.status === 401 ) { diff --git a/src/app/layouts/app-layout/app-layout.css b/src/app/layouts/app-layout/app-layout.css index 17eb963..b3fe525 100644 --- a/src/app/layouts/app-layout/app-layout.css +++ b/src/app/layouts/app-layout/app-layout.css @@ -80,3 +80,22 @@ .app-user-trigger:hover { background: rgba(255, 255, 255, 0.92); } + +.app-password-success-banner { + position: fixed; + top: 76px; + left: 50%; + z-index: 10010; + display: inline-flex; + align-items: center; + gap: 8px; + transform: translateX(-50%); + border: 1px solid #86efac; + border-radius: 999px; + background: #f0fdf4; + box-shadow: 0 14px 30px rgba(15, 23, 42, 0.14); + color: #166534; + font-size: 13px; + font-weight: 700; + padding: 10px 14px; +} diff --git a/src/app/layouts/app-layout/app-layout.html b/src/app/layouts/app-layout/app-layout.html index b3b49f1..dca7c80 100644 --- a/src/app/layouts/app-layout/app-layout.html +++ b/src/app/layouts/app-layout/app-layout.html @@ -27,11 +27,18 @@ - @if (changePasswordOpen && loggedUser()?.username; as username) { + @if (changePasswordSuccess()) { +
+ + {{ changePasswordSuccess() }} +
+ } + + @if (changePasswordOpen() && loggedUser()?.username; as username) { diff --git a/src/app/layouts/app-layout/app-layout.ts b/src/app/layouts/app-layout/app-layout.ts index 4948a2a..c15dab2 100644 --- a/src/app/layouts/app-layout/app-layout.ts +++ b/src/app/layouts/app-layout/app-layout.ts @@ -1,4 +1,4 @@ -import { afterNextRender, Component, inject } from '@angular/core'; +import { afterNextRender, Component, inject, signal } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; @@ -26,9 +26,10 @@ export class AppLayout { private containersService = inject(ContainersService); loggedUser = this.authService.loggedInUser; - changePasswordOpen = false; - changePasswordSaving = false; - changePasswordError: string | null = null; + changePasswordOpen = signal(false); + changePasswordSaving = signal(false); + changePasswordError = signal(null); + changePasswordSuccess = signal(null); constructor() { afterNextRender(() => { @@ -48,29 +49,33 @@ logout() { openChangePasswordDialog() { if (!this.loggedUser()?.username?.trim()) return; - this.changePasswordError = null; - this.changePasswordOpen = true; + this.changePasswordError.set(null); + this.changePasswordOpen.set(true); } closeChangePasswordDialog() { - if (this.changePasswordSaving) return; - this.changePasswordOpen = false; - this.changePasswordError = null; + if (this.changePasswordSaving()) return; + this.changePasswordOpen.set(false); + this.changePasswordError.set(null); } submitPasswordChange(request: ChangePasswordRequest) { - this.changePasswordSaving = true; - this.changePasswordError = null; + this.changePasswordSaving.set(true); + this.changePasswordError.set(null); this.authService.changePassword(request).subscribe({ next: () => { - this.changePasswordSaving = false; - this.changePasswordOpen = false; - window.alert('Password changed successfully.'); + this.changePasswordSaving.set(false); + this.changePasswordOpen.set(false); + this.changePasswordError.set(null); + this.changePasswordSuccess.set('Password changed successfully.'); + setTimeout(() => { + this.changePasswordSuccess.set(null); + }, 3000); }, error: (error) => { - this.changePasswordSaving = false; - this.changePasswordError = error instanceof Error ? error.message : 'Unable to change password.'; + this.changePasswordSaving.set(false); + this.changePasswordError.set(error instanceof Error ? error.message : 'Unable to change password.'); } }); } diff --git a/src/app/services/authorization/authorization-call.ts b/src/app/services/authorization/authorization-call.ts index 19bc26a..f540eeb 100644 --- a/src/app/services/authorization/authorization-call.ts +++ b/src/app/services/authorization/authorization-call.ts @@ -42,7 +42,45 @@ export class AuthorizationCallService extends AuthorizationCallServiceBase { } override changePassword(request: ChangePasswordRequest): Observable { - return this.http.post(`${environment.apiUrl}/auth/change-password`, request); + return this.http + .post(`${environment.apiUrl}/auth/change-password`, request, { responseType: 'text' }) + .pipe( + map(() => undefined), + catchError((error: unknown) => { + if (error instanceof HttpErrorResponse) { + const message = this.extractHttpErrorMessage(error) + ?? (error.status === 400 ? 'Missing required fields.' : null) + ?? (error.status === 401 ? 'Current password is invalid' : null) + ?? (error.status === 404 ? 'User not found' : null) + ?? 'Unable to change password.'; + return throwError(() => new Error(message)); + } + return throwError(() => error); + }) + ); + } + + private extractHttpErrorMessage(error: HttpErrorResponse): string | null { + const payload = error.error; + if (typeof payload === 'string' && payload.trim().length > 0) { + return payload.trim(); + } + if (payload && typeof payload === 'object') { + const record = payload as Record; + const directMessage = record['message']; + if (typeof directMessage === 'string' && directMessage.trim().length > 0) { + return directMessage.trim(); + } + const errorMessage = record['error']; + if (typeof errorMessage === 'string' && errorMessage.trim().length > 0) { + return errorMessage.trim(); + } + const details = record['details']; + if (typeof details === 'string' && details.trim().length > 0) { + return details.trim(); + } + } + return null; } private extractToken(...sources: Array | undefined>): unknown { diff --git a/src/app/services/dialogs/node-settings-dialog.ts b/src/app/services/dialogs/node-settings-dialog.ts index 21fcdcb..b6ec9d9 100644 --- a/src/app/services/dialogs/node-settings-dialog.ts +++ b/src/app/services/dialogs/node-settings-dialog.ts @@ -32,6 +32,7 @@ export type NodeSettingsDialogInput = { title?: string; fields: NodeSettingField[]; initial?: NodeSettingsValues; + previewOnly?: boolean; onValuesChange?: (draft: NodeSettingsValues) => Promise | NodeSettingsDialogRefresh | null; }; @@ -42,6 +43,7 @@ export class NodeSettingsDialogService { title: string; fields: NodeSettingField[]; initial: NodeSettingsValues; + previewOnly: boolean; onValuesChange: ((draft: NodeSettingsValues) => Promise | NodeSettingsDialogRefresh | null) | null; resolve: (value: NodeSettingsValues | null) => void; @@ -55,6 +57,7 @@ export class NodeSettingsDialogService { title: input.title ?? "Node Settings", fields: input.fields, initial: input.initial ?? {}, + previewOnly: input.previewOnly === true, onValuesChange: input.onValuesChange ?? null, resolve }); diff --git a/src/app/shared/change-password-dialog/change-password-dialog.html b/src/app/shared/change-password-dialog/change-password-dialog.html index f9bac3e..d6a1d1c 100644 --- a/src/app/shared/change-password-dialog/change-password-dialog.html +++ b/src/app/shared/change-password-dialog/change-password-dialog.html @@ -32,9 +32,6 @@ - @if (isInvalid(passwordForm.newPassword())) { - {{ passwordForm.newPassword().errors()[0].message }} - }
diff --git a/src/app/shared/node-settings-dialog/node-settings-dialog.html b/src/app/shared/node-settings-dialog/node-settings-dialog.html index 4a2521b..26653b2 100644 --- a/src/app/shared/node-settings-dialog/node-settings-dialog.html +++ b/src/app/shared/node-settings-dialog/node-settings-dialog.html @@ -106,8 +106,12 @@
+ @if (isPreviewOnly()) { + + } @else { + }
diff --git a/src/app/shared/node-settings-dialog/node-settings-dialog.ts b/src/app/shared/node-settings-dialog/node-settings-dialog.ts index 05fc9ea..f5d4dfe 100644 --- a/src/app/shared/node-settings-dialog/node-settings-dialog.ts +++ b/src/app/shared/node-settings-dialog/node-settings-dialog.ts @@ -56,6 +56,10 @@ export class NodeSettingsDialogHostComponent { this.dialog.close({ ...this.draft }); } + isPreviewOnly(): boolean { + return this.state()?.previewOnly === true; + } + setFieldValue(key: string, value: string | boolean) { this.draft[key] = value; void this.refreshFieldsFromDraft(); diff --git a/src/app/shared/nodes/container-node/container-node.css b/src/app/shared/nodes/container-node/container-node.css index dfe9a1b..a2a9a05 100644 --- a/src/app/shared/nodes/container-node/container-node.css +++ b/src/app/shared/nodes/container-node/container-node.css @@ -157,6 +157,15 @@ color: #f8fafc; } +.container-node__clone { + border: 0; + width: 30px; + height: 30px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.22); + color: #eff6ff; +} + .container-node__header-actions { margin-left: auto; display: inline-flex; diff --git a/src/app/shared/nodes/container-node/container-node.html b/src/app/shared/nodes/container-node/container-node.html index 7aec37f..e2b544a 100644 --- a/src/app/shared/nodes/container-node/container-node.html +++ b/src/app/shared/nodes/container-node/container-node.html @@ -32,7 +32,10 @@ } @if (!isReadonly) { - + } diff --git a/src/app/shared/nodes/container-node/container-node.ts b/src/app/shared/nodes/container-node/container-node.ts index 5eb30b9..0742951 100644 --- a/src/app/shared/nodes/container-node/container-node.ts +++ b/src/app/shared/nodes/container-node/container-node.ts @@ -1,5 +1,6 @@ import { CommonModule } from '@angular/common'; import { ChangeDetectorRef, Component, HostBinding, Input, inject } from '@angular/core'; +import { MatTooltipModule } from '@angular/material/tooltip'; import { currentFlowPortValueKind, flowValueKindLabel, FlowBlock, FlowContainer, FlowData } from '@models/flow'; import { NodeSettingField, NodeSettingOption, NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog'; import { ContainersService } from '@services/containers/containers'; @@ -61,7 +62,7 @@ type StructuredRetrieverConfig = { @Component({ selector: 'app-container-node', - imports: [CommonModule, ReteModule], + imports: [CommonModule, ReteModule, MatTooltipModule], templateUrl: './container-node.html', styleUrl: './container-node.css', host: { @@ -435,6 +436,17 @@ export class ContainerNodeComponent { this.deleteConfirmOpen = false; } + cloneCurrentNode(event?: Event) { + event?.preventDefault(); + event?.stopPropagation(); + if (this.isReadonly) return; + + const cloneNode = this.data?.data?.cloneNode; + if (typeof cloneNode === 'function') { + void cloneNode(); + } + } + openSubflowPreview(event?: Event) { event?.preventDefault(); event?.stopPropagation(); diff --git a/src/app/shared/nodes/generic-node/generic-node.css b/src/app/shared/nodes/generic-node/generic-node.css index d28d674..55495b6 100644 --- a/src/app/shared/nodes/generic-node/generic-node.css +++ b/src/app/shared/nodes/generic-node/generic-node.css @@ -366,6 +366,26 @@ transition: background-color 0.15s ease; } +.llm-clone-btn { + position: relative; + z-index: 146; + width: 24px; + height: 24px; + border-radius: 999px; + border: 1px solid rgba(191, 219, 254, 0.7); + background: rgba(30, 64, 175, 0.18); + color: #eff6ff; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background-color 0.15s ease; +} + +.llm-clone-btn:hover { + background: rgba(30, 64, 175, 0.34); +} + .llm-delete-btn:hover { background: rgba(127, 29, 29, 0.35); } diff --git a/src/app/shared/nodes/generic-node/generic-node.html b/src/app/shared/nodes/generic-node/generic-node.html index 0a62268..285d890 100644 --- a/src/app/shared/nodes/generic-node/generic-node.html +++ b/src/app/shared/nodes/generic-node/generic-node.html @@ -66,7 +66,10 @@ } @if (!isReadonly) { - + } diff --git a/src/app/shared/nodes/generic-node/generic-node.ts b/src/app/shared/nodes/generic-node/generic-node.ts index db7be6e..007f00a 100644 --- a/src/app/shared/nodes/generic-node/generic-node.ts +++ b/src/app/shared/nodes/generic-node/generic-node.ts @@ -1,6 +1,7 @@ import { CommonModule } from '@angular/common'; import { ChangeDetectorRef, Component, HostBinding, inject, Input } from '@angular/core'; import { FormsModule } from '@angular/forms'; +import { MatTooltipModule } from '@angular/material/tooltip'; import { BlockType, currentFlowPortValueKind, flowValueKindLabel, FlowData, FlowPort, FlowValueKind, normalizeFlowPortValueKinds } from '@models/flow'; import { ClassicPreset } from 'rete'; import { ReteModule } from 'rete-angular-plugin/21'; @@ -132,7 +133,7 @@ type RichContentView = { @Component({ selector: 'app-generic-node', - imports: [CommonModule, FormsModule, ReteModule], + imports: [CommonModule, FormsModule, ReteModule, MatTooltipModule], templateUrl: './generic-node.html', styleUrl: './generic-node.css', host: { @@ -390,6 +391,17 @@ export class GenericNodeComponent { this.deleteConfirmOpen = false; } + async cloneCurrentNode(event?: Event) { + event?.preventDefault(); + event?.stopPropagation(); + if (this.isReadonly) return; + + const cloneNode = this.data?.data?.cloneNode; + if (typeof cloneNode === 'function') { + await cloneNode(); + } + } + isHumanNode(): boolean { return !!this.blockDescriptor?.interactionContract; } diff --git a/src/app/shared/nodes/task-step-node/task-step-node.css b/src/app/shared/nodes/task-step-node/task-step-node.css index 2c0a236..d7020f3 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.css +++ b/src/app/shared/nodes/task-step-node/task-step-node.css @@ -16,6 +16,11 @@ cursor: default !important; } +:host.llm-node-readonly .llm-subflow-view, +:host.llm-node-readonly .llm-subflow-view * { + cursor: pointer !important; +} + .llm-node--human { border-color: #f7d7a7; background: linear-gradient(180deg, #fffdf9 0%, #fff7ed 100%); diff --git a/src/app/shared/nodes/task-step-node/task-step-node.html b/src/app/shared/nodes/task-step-node/task-step-node.html index 737f247..d06b89f 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.html +++ b/src/app/shared/nodes/task-step-node/task-step-node.html @@ -290,11 +290,8 @@ @if (hasViewableSubflow()) {
-
-
Subflow
-
} diff --git a/src/app/shared/nodes/task-step-node/task-step-node.ts b/src/app/shared/nodes/task-step-node/task-step-node.ts index bd71faf..fab2f2f 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.ts +++ b/src/app/shared/nodes/task-step-node/task-step-node.ts @@ -288,7 +288,7 @@ export class TaskStepNodeComponent { } isContainerNode(): boolean { - return this.data?.data?.nodeFamily === 'container'; + return this.resolvedNodeFamily() === 'container'; } hasViewableSubflow(): boolean { @@ -429,14 +429,14 @@ export class TaskStepNodeComponent { event?.preventDefault(); event?.stopPropagation(); if (!field.expandable) return; - void this.openReadonlyTextDialog(field.label, field.value); + void this.openReadonlyTextDialog(field.label, this.resolvePreviewText(field.value)); } openMainContentPreview(field: MainContentView, event?: Event) { event?.preventDefault(); event?.stopPropagation(); if (!field.expandable) return; - void this.openReadonlyTextDialog(field.label, field.rawValue); + void this.openReadonlyTextDialog(field.label, this.resolvePreviewText(field.rawValue)); } private get blockConfiguration(): Record | null { @@ -540,9 +540,7 @@ export class TaskStepNodeComponent { const type = this.blockType; if (!type) return; - const nodeFamily = this.data?.data?.nodeFamily === 'container' - ? 'container' - : 'block'; + const nodeFamily = this.resolvedNodeFamily(); const cachedDescriptor = nodeFamily === 'container' ? this.containersService.peekContainerType(type) : this.blocksService.peekBlockType(type); @@ -1035,10 +1033,29 @@ export class TaskStepNodeComponent { private nodeTypeCacheKey(): string | null { const type = this.blockType; if (!type) return null; - const family = this.isContainerNode() ? 'container' : 'block'; + const family = this.resolvedNodeFamily(); return `${family}:${type}`; } + private resolvedNodeFamily(): 'block' | 'container' { + if (this.data?.data?.nodeFamily === 'container') { + return 'container'; + } + + const config = this.blockConfiguration; + const subFlow = config?.['subFlow']; + if (subFlow && typeof subFlow === 'object' && !Array.isArray(subFlow)) { + return 'container'; + } + + const type = this.blockType; + if (type && this.containersService.peekContainerType(type)) { + return 'container'; + } + + return 'block'; + } + private getGlobalCache(store: Map>, typeKey: string): Map { let cache = store.get(typeKey); if (!cache) { @@ -1087,6 +1104,7 @@ export class TaskStepNodeComponent { private async openReadonlyTextDialog(label: string, value: string) { await this.settingsDialog.open({ title: label, + previewOnly: true, fields: [ { key: 'value', @@ -1102,4 +1120,27 @@ export class TaskStepNodeComponent { }); } + private resolvePreviewText(value: string): string { + const source = String(value ?? ''); + if (!source.includes('${{')) return source; + + return source.replace(/\$\{\{\s*([^}]+?)\s*\}\}/g, (token, rawKey: string) => { + const key = String(rawKey ?? '').trim(); + if (!key) return token; + + const configInputs = this.blockConfiguration?.['__executionInputs']; + const inputs = configInputs && typeof configInputs === 'object' && !Array.isArray(configInputs) + ? configInputs as Record + : null; + if (!inputs || !Object.prototype.hasOwnProperty.call(inputs, key)) { + return token; + } + + const resolved = inputs[key]; + if (resolved == null) return token; + if (typeof resolved === 'string') return resolved; + return valueToDisplayString(resolved); + }); + } + } diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.ts b/src/app/shared/task-execution-viewer/task-execution-viewer.ts index dd6ae82..9b7c2ff 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.ts +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.ts @@ -33,6 +33,7 @@ import { import { NodeSettingField, NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog'; import { FieldRetriever } from '@services/retriever/field-retriever'; import { TaskExecutionsService } from '@services/task-executions/task-executions'; +import { ContainersService } from '@services/containers/containers'; import { firstValueFrom } from 'rxjs'; type ExecutionOutputEntry = { @@ -68,6 +69,7 @@ export class TaskExecutionViewerComponent implements OnDestroy { private humanInteractionDialog = inject(HumanInteractionDialogService); private settingsDialog = inject(NodeSettingsDialogService); private fieldRetriever = inject(FieldRetriever); + private containersService = inject(ContainersService); private readonly textInputDebounceTimers = new Map>(); private lastExecutionId: string | null = null; private lastExecutionStatus: string | null = null; @@ -254,7 +256,7 @@ export class TaskExecutionViewerComponent implements OnDestroy { const executionNode: FlowNode = { ...stepNode, - nodeFamily: stepNode.nodeFamily === 'container' ? 'container' : 'block', + nodeFamily: this.isContainerExecutionNode(stepNode) ? 'container' : 'block', specificConfiguration: { ...(stepNode.specificConfiguration ?? {}), __executionId: this.execution()?.id ?? null, @@ -773,6 +775,15 @@ export class TaskExecutionViewerComponent implements OnDestroy { }); } + private isContainerExecutionNode(stepNode: FlowNode): boolean { + if (stepNode.nodeFamily === 'container') return true; + + const subFlow = (stepNode.specificConfiguration as Record | null | undefined)?.['subFlow']; + if (subFlow && typeof subFlow === 'object' && !Array.isArray(subFlow)) return true; + + return !!this.containersService.peekContainerType(stepNode.typeName); + } + private scrollLogsToBottom() { const element = this.logsScrollViewport?.nativeElement; if (!element || this.activeAsideTab() !== 'logs') return; diff --git a/src/app/utilities/rete-editor.ts b/src/app/utilities/rete-editor.ts index 5ff7d0e..9457a69 100644 --- a/src/app/utilities/rete-editor.ts +++ b/src/app/utilities/rete-editor.ts @@ -431,11 +431,41 @@ export async function addBlockToEditor( } } }; + const cloneNode = async () => { + if (resolvedRuntime?.readonly) return; + + const currentNode = editor.getNode(node.id) as HFNode | undefined; + if (!currentNode?.data) return; + + const sourceData = currentNode.data as Record; + const sourcePosition = sourceData['position'] as { x: number; y: number } | undefined; + const nextPosition = sourcePosition + ? { x: sourcePosition.x + 48, y: sourcePosition.y + 48 } + : { x: 168, y: 148 }; + + const clonedNode = { + ...cloneValue(block), + ...cloneValue(sourceData), + id: globalThis.crypto?.randomUUID?.() ?? `${Date.now()}`, + position: nextPosition, + inputs: cloneValue((sourceData['inputs'] as FlowNode['inputs'] | undefined) ?? block.inputs ?? []), + outputs: cloneValue((sourceData['outputs'] as FlowNode['outputs'] | undefined) ?? block.outputs ?? []), + specificConfiguration: cloneValue((sourceData['specificConfiguration'] as FlowNode['specificConfiguration'] | undefined) ?? block.specificConfiguration ?? {}), + nodeFamily: sourceData['nodeFamily'] === 'container' ? 'container' : 'block', + __needsServerCreate: sourceData['nodeFamily'] === 'container' ? false : true, + __createdOnServer: false, + __isCreatingOnServer: false, + __updateBlockError: null + } as FlowNode & Record; + + await addBlockToEditor(editor, area, clonedNode, nextPosition, resolvedRuntime); + }; node.data = { ...cloneValue(block), position: position ?? block.position, __readonly: resolvedRuntime?.readonly === true, deleteNode: removeNode, + cloneNode, replaceWithCreatedNode, assignSelectedBlocksToContainer, assignImportedSubflow, diff --git a/src/styles.css b/src/styles.css index 9213e8d..55944a1 100644 --- a/src/styles.css +++ b/src/styles.css @@ -437,30 +437,30 @@ body { } .llm-subflow-section { - display: flex; - flex-direction: column; - gap: 6px; - border: 1px solid #dbe7f5; - border-radius: 12px; - background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%); - padding: 10px 12px; + margin-top: 4px; } .llm-subflow-view { - align-self: flex-start; - border: 1px solid #bfdbfe; - border-radius: 999px; - background: #eff6ff; - color: #1d4ed8; + display: flex; + width: 100%; + align-items: center; + justify-content: center; + border: 1px solid #c7d2fe; + border-radius: 12px; + background: linear-gradient(180deg, #eef4ff 0%, #e0ecff 100%); + color: #1e40af; cursor: pointer; - font-size: 11px; + font-size: 12px; font-weight: 700; line-height: 1; - padding: 7px 10px; + padding: 11px 12px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7); + transition: background 0.15s ease, border-color 0.15s ease, transform 0.15s ease; } .llm-subflow-view:hover { - background: #dbeafe; + background: linear-gradient(180deg, #dbeafe 0%, #c7ddff 100%); + border-color: #93c5fd; } .link { @apply font-medium text-primary-600 hover:underline dark:text-primary-500;