RSS Git Download  Clone
Raw Blame History 56kB 1142 lines
import { Component, Inject, OnInit, OnDestroy, ChangeDetectorRef, ChangeDetectionStrategy } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatDividerModule } from '@angular/material/divider';
import { AclUserDialogService } from '../dialogs/acl-user-dialog.service';
import { BreakpointObserver } from '@angular/cdk/layout';
import { CdkDragDrop, DragDropModule, moveItemInArray } from '@angular/cdk/drag-drop';
import { I18nService } from '../services/i18n.service';
import { NotificationService } from '../services/notification.service';
import { SettingsService } from '../services/settings.service';
import { RedisStateService } from '../services/redis-state.service';
import { CommonService } from '../services/common.service';
import { SocketService } from '../services/socket.service';
import { MainCommandService } from '../services/main-command.service';
import { ConnectionDialogService } from '../dialogs/connection-dialog.service';
import { TreecontrolSettingsDialogService } from '../dialogs/treecontrol-settings-dialog.service';
import { AiSettingsDialogService } from '../dialogs/ai-settings-dialog.service';
import { P3xrAccordionComponent } from '../components/p3xr-accordion.component';
import { P3xrButtonComponent } from '../components/p3xr-button.component';
import { switchGui } from '../../core/gui-switch';

/**
 * Settings page — Angular replacement for AngularJS p3xrSettings.
 * First complete Angular page migration.
 *
 * Contains:
 * - Connections list (add/edit/delete/connect/disconnect)
 * - License info panel
 * - Tree settings panel
 */
@Component({
    selector: 'p3xr-ng-settings',
    standalone: true,
    imports: [
        FormsModule,
        MatToolbarModule, MatButtonModule, MatIconModule, MatListModule, MatSlideToggleModule,
        MatTooltipModule, MatDividerModule, DragDropModule,
        P3xrAccordionComponent, P3xrButtonComponent,
    ],
    template: `
        <!-- Donate -->
        <p3xr-ng-accordion [title]="strings().title?.donateTitle" accordionKey="donate" [collapsible]="false">
            <div actions>
                <a href="https://www.paypal.me/patrikx3" target="_blank" rel="noreferrer" style="text-decoration: none;">
                    <p3xr-ng-button
                        [label]="(strings().title?.donate) + ' — PayPal'"
                        mdIcon="favorite">
                    </p3xr-ng-button>
                </a>
            </div>
            <div content>
                <div style="padding: 12px 16px; font-size: 13px; opacity: 0.85; line-height: 1.6;">
                    {{ strings().title?.donateDescription }}
                </div>
            </div>
        </p3xr-ng-accordion>

        @if (isPromoDomain) {
            <br/>

            <!-- Promo: AI Network Assistant (demo site only) -->
            <p3xr-ng-accordion [title]="strings().promo?.title" accordionKey="promo-network" [collapsible]="false">
                <div actions>
                    <a href="https://network.corifeus.com" target="_blank" rel="noreferrer" style="text-decoration: none;">
                        <p3xr-ng-button
                            [label]="strings().promo?.visit"
                            mdIcon="travel_explore">
                        </p3xr-ng-button>
                    </a>
                </div>
                <div content>
                    <div style="padding: 12px 16px; font-size: 13px; opacity: 0.85; line-height: 1.6;">
                        {{ strings().promo?.description }}
                    </div>
                    <div style="padding: 0 16px 12px; font-size: 11px; opacity: 0.5; line-height: 1.4;">
                        {{ strings().promo?.disclaimer }}
                    </div>
                </div>
            </p3xr-ng-accordion>
        }

        <br/>

        <!-- Connections -->
        <p3xr-ng-accordion [title]="strings().label?.connections" accordionKey="settings">
            <div actions>
                <p3xr-ng-button
                    (click)="toggleGroupMode()"
                    [label]="strings().label?.grouped"
                    [mdIcon]="groupModeEnabled ? 'check_box' : 'check_box_outline_blank'">
                </p3xr-ng-button>
                @if (!readonlyConnections) {
                    <p3xr-ng-button
                        (click)="connectionForm('new')"
                        [label]="strings().intention?.connectionAdd"
                        mdIcon="add_box">
                    </p3xr-ng-button>
                }
            </div>
            <div content>
                @if (connectionsList.length === 0) {
                    <div style="padding: 16px;">
                        {{ strings().intention?.noConnectionsInSettings }}
                    </div>
                }

                @if (connectionsList.length > 0) {
                    <div>
                        @if (groupModeEnabled) {
                        <div cdkDropList
                             [cdkDropListData]="groupedConnections"
                             (cdkDropListDropped)="dropGroup($event)"
                             [cdkDropListEnterPredicate]="groupDropPredicate"
                             class="p3xr-group-drop-list">
                        @for (group of groupedConnections; track group.name) {
                            <div cdkDrag class="p3xr-connection-group-block" [cdkDragData]="group">
                            <div class="p3xr-connection-group-header" cdkDragHandle (click)="toggleGroup(group.name)">
                                <mat-icon style="font-size: 18px; width: 18px; height: 18px;">{{ collapsedGroups.has(group.name) ? 'chevron_right' : 'expand_more' }}</mat-icon>
                                <span>{{ getGroupDisplayName(group.name) }}</span>
                                <span style="opacity: 0.5; font-weight: 400; font-size: 12px;">({{ group.connections.length }})</span>
                            </div>
                            @if (!collapsedGroups.has(group.name)) {
                                <div cdkDropList
                                     [cdkDropListData]="group.connections"
                                     (cdkDropListDropped)="dropConnection($event, group.name)"
                                     [cdkDropListEnterPredicate]="connectionDropPredicate">
                                @for (connection of group.connections; track connection.id; let last = $last) {
                                <div class="p3xr-connection-item" cdkDrag [cdkDragData]="connection">
                                    <div class="p3xr-connection-info">
                                        <div style="font-weight: 700;">{{ connection.name }}</div>
                                        <div style="font-size: 13px; opacity: 0.7;">{{ connection.host }}:{{ connection.port }}</div>
                                        <div style="font-size: 13px; opacity: 0.7;">
                                            @for (entry of getConnectionClients(connection); track entry.key) {
                                                {{ strings().page?.overview?.connectedCount?.({ length: entry.clients }) }}
                                            }
                                            &nbsp;
                                        </div>
                                    </div>

                                    @if (currentConnectionId !== connection.id) {
                                        @if (isXs) {
                                            <button mat-mini-fab class="btn-accent" (click)="connect(connection)"
                                                [matTooltip]="strings().intention?.connect"
                                                [attr.aria-label]="strings().intention?.connect">
                                                <mat-icon>power</mat-icon>
                                            </button>
                                        } @else {
                                            <button mat-flat-button class="btn-accent" (click)="connect(connection)">
                                                <mat-icon>power</mat-icon>
                                                <span>{{ strings().intention?.connect }}</span>
                                            </button>
                                        }
                                    }

                                    @if (currentConnectionId === connection.id) {
                                        @if (isXs) {
                                            <button mat-mini-fab class="btn-accent" (click)="disconnect()"
                                                [matTooltip]="strings().intention?.disconnect"
                                                matTooltipPosition="above"
                                                [attr.aria-label]="strings().intention?.disconnect">
                                                <i class="fa fa-power-off"></i>
                                            </button>
                                        } @else {
                                            <button mat-flat-button class="btn-accent" (click)="disconnect()">
                                                <i class="fa fa-power-off"></i>
                                                <span>{{ strings().intention?.disconnect }}</span>
                                            </button>
                                        }
                                    }

                                    @if (!readonlyConnections) {
                                        @if (isXs) {
                                            <button mat-mini-fab class="btn-warn" (click)="deleteConnection(connection, $event)"
                                                [matTooltip]="strings().intention?.delete"
                                                [attr.aria-label]="strings().intention?.delete">
                                                <mat-icon>delete_forever</mat-icon>
                                            </button>
                                        } @else {
                                            <button mat-flat-button class="btn-warn" (click)="deleteConnection(connection, $event)">
                                                <mat-icon>delete_forever</mat-icon>
                                                <span>{{ strings().intention?.delete }}</span>
                                            </button>
                                        }
                                        @if (isXs) {
                                            <button mat-mini-fab class="btn-primary" (click)="connectionForm('edit', connection)"
                                                [matTooltip]="strings().intention?.edit"
                                                [attr.aria-label]="strings().intention?.edit">
                                                <mat-icon>edit</mat-icon>
                                            </button>
                                        } @else {
                                            <button mat-flat-button class="btn-primary" (click)="connectionForm('edit', connection)">
                                                <mat-icon>edit</mat-icon>
                                                <span>{{ strings().intention?.edit }}</span>
                                            </button>
                                        }
                                    }

                                    @if (readonlyConnections) {
                                        @if (isXs) {
                                            <button mat-mini-fab class="btn-primary" (click)="connectionForm('edit', connection)"
                                                [matTooltip]="strings().intention?.view"
                                                [attr.aria-label]="strings().intention?.view">
                                                <mat-icon>mode_comment</mat-icon>
                                            </button>
                                        } @else {
                                            <button mat-flat-button class="btn-primary" (click)="connectionForm('edit', connection)">
                                                <mat-icon>mode_comment</mat-icon>
                                                <span>{{ strings().intention?.view }}</span>
                                            </button>
                                        }
                                    }
                                </div>
                                @if (!last) { <mat-divider></mat-divider> }
                                }
                                </div>
                            }
                            </div>
                        }
                        </div>
                        }
                        @if (!groupModeEnabled) {
                            <div cdkDropList
                                 [cdkDropListData]="connectionsList"
                                 (cdkDropListDropped)="dropUngroupedConnection($event)">
                            @for (connection of connectionsList; track connection.id; let last = $last) {
                                <div class="p3xr-connection-item" cdkDrag [cdkDragData]="connection">
                                    <div class="p3xr-connection-info">
                                        <div style="font-weight: 700;">{{ connection.name }}</div>
                                        <div style="font-size: 13px; opacity: 0.7;">{{ connection.host }}:{{ connection.port }}</div>
                                        <div style="font-size: 13px; opacity: 0.7;">
                                            @for (entry of getConnectionClients(connection); track entry.key) {
                                                {{ strings().page?.overview?.connectedCount?.({ length: entry.clients }) }}
                                            }
                                            &nbsp;
                                        </div>
                                    </div>

                                    @if (currentConnectionId !== connection.id) {
                                        @if (isXs) {
                                            <button mat-mini-fab class="btn-accent" (click)="connect(connection)"
                                                [matTooltip]="strings().intention?.connect"
                                                [attr.aria-label]="strings().intention?.connect">
                                                <mat-icon>power</mat-icon>
                                            </button>
                                        } @else {
                                            <button mat-flat-button class="btn-accent" (click)="connect(connection)">
                                                <mat-icon>power</mat-icon>
                                                <span>{{ strings().intention?.connect }}</span>
                                            </button>
                                        }
                                    }

                                    @if (currentConnectionId === connection.id) {
                                        @if (isXs) {
                                            <button mat-mini-fab class="btn-accent" (click)="disconnect()"
                                                [matTooltip]="strings().intention?.disconnect"
                                                matTooltipPosition="above"
                                                [attr.aria-label]="strings().intention?.disconnect">
                                                <i class="fa fa-power-off"></i>
                                            </button>
                                        } @else {
                                            <button mat-flat-button class="btn-accent" (click)="disconnect()">
                                                <i class="fa fa-power-off"></i>
                                                <span>{{ strings().intention?.disconnect }}</span>
                                            </button>
                                        }
                                    }

                                    @if (!readonlyConnections) {
                                        @if (isXs) {
                                            <button mat-mini-fab class="btn-warn" (click)="deleteConnection(connection, $event)"
                                                [matTooltip]="strings().intention?.delete"
                                                [attr.aria-label]="strings().intention?.delete">
                                                <mat-icon>delete_forever</mat-icon>
                                            </button>
                                        } @else {
                                            <button mat-flat-button class="btn-warn" (click)="deleteConnection(connection, $event)">
                                                <mat-icon>delete_forever</mat-icon>
                                                <span>{{ strings().intention?.delete }}</span>
                                            </button>
                                        }
                                        @if (isXs) {
                                            <button mat-mini-fab class="btn-primary" (click)="connectionForm('edit', connection)"
                                                [matTooltip]="strings().intention?.edit"
                                                [attr.aria-label]="strings().intention?.edit">
                                                <mat-icon>edit</mat-icon>
                                            </button>
                                        } @else {
                                            <button mat-flat-button class="btn-primary" (click)="connectionForm('edit', connection)">
                                                <mat-icon>edit</mat-icon>
                                                <span>{{ strings().intention?.edit }}</span>
                                            </button>
                                        }
                                    }

                                    @if (readonlyConnections) {
                                        @if (isXs) {
                                            <button mat-mini-fab class="btn-primary" (click)="connectionForm('edit', connection)"
                                                [matTooltip]="strings().intention?.view"
                                                [attr.aria-label]="strings().intention?.view">
                                                <mat-icon>mode_comment</mat-icon>
                                            </button>
                                        } @else {
                                            <button mat-flat-button class="btn-primary" (click)="connectionForm('edit', connection)">
                                                <mat-icon>mode_comment</mat-icon>
                                                <span>{{ strings().intention?.view }}</span>
                                            </button>
                                        }
                                    }
                                </div>
                                @if (!last) { <mat-divider></mat-divider> }
                            }
                            </div>
                        }
                    </div>
                }
            </div>
        </p3xr-ng-accordion>

        @if (currentConnectionId) {
            <br />
            <!-- ACL Users -->
            <p3xr-ng-accordion [title]="(strings().page?.acl?.title) + ' — ' + currentConnectionName" accordionKey="acl-users">
                <div actions>
                    <p3xr-ng-button
                        (click)="loadAclUsers(); $event.stopPropagation()"
                        [label]="strings().intention?.refresh"
                        mdIcon="refresh">
                    </p3xr-ng-button>
                    @if (!readonlyConnections) {
                        <p3xr-ng-button
                            (click)="openAclCreate(); $event.stopPropagation()"
                            [label]="strings().page?.acl?.createUser"
                            mdIcon="person_add">
                        </p3xr-ng-button>
                    }
                </div>
                <div content>
                    @if (aclLoading) {
                        <div style="padding: 16px; opacity: 0.6;">{{ strings().page?.acl?.loading }}</div>
                    } @else if (!aclUsers) {
                        <div style="padding: 16px; opacity: 0.6;">{{ strings().page?.acl?.noUsers }}</div>
                    } @else {
                        <div class="p3xr-acl-users-list">
                            @for (user of aclUsers; track user.name; let last = $last) {
                                <div class="p3xr-connection-item" [class.p3xr-acl-clickable]="!readonlyConnections" (click)="!readonlyConnections && openAclEdit(user)">
                                    <div class="p3xr-connection-info" style="display: flex; align-items: center;">
                                        <span style="font-weight: 700;">{{ user.name }}</span>
                                        @if (user.name === aclCurrentUser) {
                                            <span style="opacity: 0.5; margin-left: 6px; font-size: 11px; line-height: 1;">({{ strings().page?.acl?.currentUser }})</span>
                                        }
                                    </div>
                                    @if (!user.enabled) {
                                        <mat-icon style="color: var(--p3xr-btn-warn-bg, #f44336); font-size: 20px; width: 20px; height: 20px;"
                                            [matTooltip]="strings().page?.acl?.disabled">
                                            warning
                                        </mat-icon>
                                    }
                                    @if (!readonlyConnections) {
                                        @if (user.name !== 'default' && user.name !== aclCurrentUser) {
                                            @if (isXs) {
                                                <button mat-mini-fab class="btn-warn" (click)="deleteAclUser(user.name); $event.stopPropagation()"
                                                    [matTooltip]="strings().page?.acl?.deleteUser"
                                                    [attr.aria-label]="strings().page?.acl?.deleteUser">
                                                    <mat-icon>delete</mat-icon>
                                                </button>
                                            } @else {
                                                <button mat-flat-button class="btn-warn" (click)="deleteAclUser(user.name); $event.stopPropagation()">
                                                    <mat-icon>delete</mat-icon>
                                                    <span>{{ strings().page?.acl?.deleteUser }}</span>
                                                </button>
                                            }
                                        }
                                        @if (isXs) {
                                            <button mat-mini-fab class="btn-primary" (click)="openAclEdit(user); $event.stopPropagation()"
                                                [matTooltip]="strings().page?.acl?.editUser"
                                                [attr.aria-label]="strings().page?.acl?.editUser">
                                                <mat-icon>edit</mat-icon>
                                            </button>
                                        } @else {
                                            <button mat-flat-button class="btn-primary" (click)="openAclEdit(user); $event.stopPropagation()">
                                                <mat-icon>edit</mat-icon>
                                                <span>{{ strings().page?.acl?.editUser }}</span>
                                            </button>
                                        }
                                    }
                                </div>
                                @if (!last) { <mat-divider></mat-divider> }
                            }
                        </div>
                    }
                </div>
            </p3xr-ng-accordion>
        }

        <br />

        <!-- GUI Framework -->
        <p3xr-ng-accordion title="GUI" accordionKey="gui-framework">
            <div content>
                <div style="display: flex; justify-content: flex-end; padding: 16px;">
                    <div class="p3xr-gui-toggle">
                        <span class="p3xr-gui-toggle-active"><i class="fab fa-angular" style="color:#dd0031;margin-right:6px;font-size:16px;"></i>Angular</span>
                        <span class="p3xr-gui-toggle-item" (click)="switchToReact()"><i class="fab fa-react" style="color:#61dafb;margin-right:6px;font-size:18px;"></i>React</span>
                        <span class="p3xr-gui-toggle-item" (click)="switchToVue()"><i class="fab fa-vuejs" style="color:#42b883;margin-right:6px;font-size:18px;"></i>Vue</span>
                    </div>
                </div>
            </div>
        </p3xr-ng-accordion>

        <br />

        <!-- AI Settings -->
        <p3xr-ng-accordion [title]="strings().label?.aiSettings" accordionKey="ai-settings">
            <div actions>
                @if (!readonlyConnections && !isGroqApiKeyReadonly()) {
                    <p3xr-ng-button
                        (click)="openAiSettings($event)"
                        [label]="strings().intention?.edit"
                        mdIcon="edit">
                    </p3xr-ng-button>
                }
            </div>
            <div content>
                <mat-list>
                    <mat-list-item (click)="$event.stopPropagation()">
                        <div class="p3xr-settings-pair-row">
                            <div class="p3xr-settings-row-label">{{ strings().label?.aiEnabled }}</div>
                            <div class="p3xr-settings-row-value">
                                <mat-slide-toggle [checked]="isAiEnabled()" [disabled]="isAiReadonly()" (change)="toggleAiEnabled($event.checked)"></mat-slide-toggle>
                            </div>
                        </div>
                    </mat-list-item>
                    @if (isAiEnabled() && hasGroqApiKey()) {
                        <mat-divider></mat-divider>
                        <mat-list-item (click)="$event.stopPropagation()">
                            <div style="width: 100%;">
                                <div class="p3xr-settings-pair-row">
                                    <div class="p3xr-settings-row-label">{{ strings().label?.aiRouteViaNetwork }}</div>
                                    <div class="p3xr-settings-row-value">
                                        <mat-slide-toggle [checked]="!isUseOwnKey()" [disabled]="isAiReadonly()" (change)="toggleUseOwnKey(!$event.checked)"></mat-slide-toggle>
                                    </div>
                                </div>
                                <div class="p3xr-settings-hint">
                                    {{ isUseOwnKey() ? (strings().label?.aiRoutingDirect) : (strings().label?.aiRoutingNetwork) }}
                                    @if (!isUseOwnKey()) {
                                        <a href="https://console.groq.com" target="_blank" style="color: inherit; text-decoration: underline;">console.groq.com</a>
                                    }
                                </div>
                            </div>
                        </mat-list-item>
                        <mat-divider></mat-divider>
                        <mat-list-item>
                            <div class="p3xr-settings-pair-row">
                                <div class="p3xr-settings-row-label">{{ strings().label?.aiGroqApiKey }}</div>
                                <div class="p3xr-settings-row-value" style="font-family: monospace;">{{ state.cfg()?.groqApiKeyMasked }}</div>
                            </div>
                        </mat-list-item>
                    }
                </mat-list>
            </div>
        </p3xr-ng-accordion>

        <br />

        <!-- Desktop Notifications -->
        <p3xr-ng-accordion [title]="strings().label?.desktopNotifications" accordionKey="desktop-notifications">
            <div content>
                <mat-list>
                    <mat-list-item>
                        <div style="display: flex; width: 100%; align-items: center;">
                            <span class="p3xr-settings-label" style="flex: 1;">{{ strings().label?.desktopNotificationsEnabled }}</span>
                            <mat-slide-toggle [checked]="notificationService.isEnabled()" (change)="notificationService.setEnabled($event.checked)"></mat-slide-toggle>
                        </div>
                    </mat-list-item>
                    <mat-divider></mat-divider>
                    <mat-list-item>
                        <div style="font-size: 12px; opacity: 0.7;">{{ strings().label?.desktopNotificationsInfo }}</div>
                    </mat-list-item>
                </mat-list>
            </div>
        </p3xr-ng-accordion>

        <br />

        <!-- Tree Settings -->
        <p3xr-ng-accordion [title]="strings().form?.treeSettings?.label?.formName" accordionKey="tree-settings">
            <div actions>
                <p3xr-ng-button
                    (click)="openTreeSettings($event)"
                    [label]="strings().intention?.edit"
                    mdIcon="edit">
                </p3xr-ng-button>
            </div>
            <div content>
                <mat-list class="p3xr-tree-settings-list">
                    <mat-list-item (click)="openTreeSettings($event)">
                        <div style="display: flex; width: 100%;"><span class="p3xr-settings-label" style="flex: 1;">{{ strings().form?.treeSettings?.field?.treeSeparator }}</span>
                        <span>{{ settings.redisTreeDivider() || strings().label?.treeSeparatorEmptyNote }}</span></div>
                    </mat-list-item>
                    <mat-divider></mat-divider>

                    <mat-list-item (click)="openTreeSettings($event)">
                        <div><div style="display: flex; width: 100%;"><span class="p3xr-settings-label" style="flex: 1;">{{ strings().form?.treeSettings?.field?.page }}</span><span class="p3xr-settings-value">{{ settings.pageCount() }}</span></div>
                        <div class="p3xr-settings-hint">{{ strings().form?.treeSettings?.error?.page }}</div></div>
                    </mat-list-item>
                    <mat-divider></mat-divider>

                    <mat-list-item (click)="openTreeSettings($event)">
                        <div><div style="display: flex; width: 100%;"><span class="p3xr-settings-label" style="flex: 1;">{{ strings().form?.treeSettings?.field?.keyPageCount }}</span><span class="p3xr-settings-value">{{ settings.keyPageCount() }}</span></div>
                        <div class="p3xr-settings-hint">{{ strings().form?.treeSettings?.error?.keyPageCount }}</div></div>
                    </mat-list-item>
                    <mat-divider></mat-divider>

                    <mat-list-item (click)="openTreeSettings($event)">
                        <div><div style="display: flex; width: 100%;"><span class="p3xr-settings-label" style="flex: 1;">{{ strings().form?.treeSettings?.maxValueDisplay }}</span><span class="p3xr-settings-value">{{ settings.maxValueDisplay() }}</span></div>
                        <div class="p3xr-settings-hint">{{ strings().form?.treeSettings?.maxValueDisplayInfo }}</div></div>
                    </mat-list-item>
                    <mat-divider></mat-divider>

                    <mat-list-item (click)="openTreeSettings($event)">
                        <div><div style="display: flex; width: 100%;"><span class="p3xr-settings-label" style="flex: 1;">{{ strings().form?.treeSettings?.maxKeys }}</span><span class="p3xr-settings-value">{{ settings.maxKeys() }}</span></div>
                        <div class="p3xr-settings-hint">{{ strings().form?.treeSettings?.maxKeysInfo }}</div></div>
                    </mat-list-item>
                    <mat-divider></mat-divider>

                    <mat-list-item (click)="openTreeSettings($event)">
                        <div style="display: flex; width: 100%;"><span class="p3xr-settings-label" style="flex: 1;">{{ strings().form?.treeSettings?.field?.keysSort }}</span>
                        <span>{{ settings.keysSort() ? strings().label?.keysSort?.on : strings().label?.keysSort?.off }}</span></div>
                    </mat-list-item>
                    <mat-divider></mat-divider>

                    <mat-list-item (click)="openTreeSettings($event)">
                        <div style="display: flex; width: 100%;"><span class="p3xr-settings-label" style="flex: 1;">{{ strings().form?.treeSettings?.field?.searchMode }}</span>
                        <span>{{ settings.searchClientSide() ? strings().form?.treeSettings?.label?.searchModeClient : strings().form?.treeSettings?.label?.searchModeServer }}</span></div>
                    </mat-list-item>
                    <mat-divider></mat-divider>

                    <mat-list-item (click)="openTreeSettings($event)">
                        <div style="display: flex; width: 100%;"><span class="p3xr-settings-label" style="flex: 1;">{{ strings().form?.treeSettings?.field?.searchModeStartsWith }}</span>
                        <span>{{ settings.searchStartsWith() ? strings().form?.treeSettings?.label?.searchModeStartsWith : strings().form?.treeSettings?.label?.searchModeIncludes }}</span></div>
                    </mat-list-item>
                    <mat-divider></mat-divider>

                    <mat-list-item (click)="openTreeSettings($event)">
                        <div style="display: flex; width: 100%; font-weight: 500;">{{ settings.jsonFormat() === 2 ? strings().form?.treeSettings?.label?.jsonFormatTwoSpace : strings().form?.treeSettings?.label?.jsonFormatFourSpace }}</div>
                    </mat-list-item>
                    <mat-divider></mat-divider>

                    <mat-list-item (click)="openTreeSettings($event)">
                        <div style="display: flex; width: 100%; font-weight: 500;">{{ settings.animation() ? strings().form?.treeSettings?.label?.animation : strings().form?.treeSettings?.label?.noAnimation }}</div>
                    </mat-list-item>
                    <mat-divider></mat-divider>

                    <mat-list-item (click)="openTreeSettings($event)">
                        <div><div style="display: flex; width: 100%; font-weight: 500;">{{ settings.undoEnabled() ? (strings().form?.treeSettings?.label?.undoEnabled) : (strings().form?.treeSettings?.label?.undoDisabled) }}</div>
                        <div class="p3xr-settings-hint">{{ strings().form?.treeSettings?.undoHint }}</div></div>
                    </mat-list-item>
                    <mat-divider></mat-divider>

                    <mat-list-item (click)="openTreeSettings($event)">
                        <div style="display: flex; width: 100%; font-weight: 500;">{{ settings.showDiffBeforeSave() ? (strings().form?.treeSettings?.label?.diffEnabled) : (strings().form?.treeSettings?.label?.diffDisabled) }}</div>
                    </mat-list-item>
                </mat-list>
            </div>
        </p3xr-ng-accordion>
    `,
    styles: [`
        :host { display: block; color: var(--mat-app-text-color, inherit); }
        .p3xr-settings-hint {
            font-size: 12px;
            color: var(--mat-app-text-color, rgba(0, 0, 0, 0.54));
            opacity: 0.7;
        }
        /* GUI toggle */
        .p3xr-gui-toggle {
            display: inline-flex;
            align-items: stretch;
            border-radius: 4px;
            overflow: hidden;
            border: 1px solid var(--p3xr-border-color, rgba(0,0,0,0.12));
        }
        .p3xr-gui-toggle-active,
        .p3xr-gui-toggle-item {
            padding: 8px 12px;
            font-size: 14px;
            user-select: none;
            display: inline-flex;
            align-items: center;
        }
        .p3xr-gui-toggle-active {
            font-weight: 700;
            background-color: var(--p3xr-btn-primary-bg);
            color: var(--p3xr-btn-primary-color);
        }
        .p3xr-gui-toggle-item {
            font-weight: 500;
            cursor: pointer;
        }
        .p3xr-gui-toggle-active i[class*="fa-"] {
            text-shadow: 0 0 3px rgba(0,0,0,0.6), 0 0 8px rgba(0,0,0,0.3);
        }
        .p3xr-gui-toggle-item:hover {
            background-color: var(--p3xr-hover-bg);
        }
        /* Wide screens: show button text, hide tooltip */
        .hide-xs { display: inline; }
        .show-xs-tooltip { display: none; }
        /* Small screens: hide text, show icon-only square buttons */
        @media (max-width: 599px) {
            .hide-xs { display: none !important; }
            /* Buttons become square icon buttons on mobile */
            .p3xr-connection-item button {
                min-width: 40px !important;
                width: 40px !important;
                height: 40px !important;
                padding: 0 !important;
                margin: 2px !important;
                display: inline-flex !important;
                align-items: center !important;
                justify-content: center !important;
            }
            .p3xr-connection-item button mat-icon,
            .p3xr-connection-item button i {
                margin: 0 !important;
            }
        }
        /* Connection items: match production md-list-item */
        .p3xr-connection-item {
            display: flex;
            align-items: center;
            gap: 4px;
            padding: 8px 8px 8px 16px;
            min-height: 56px;
            box-sizing: border-box;
        }
        .p3xr-connection-info { flex: 1; min-width: 0; overflow: hidden; }
        .p3xr-connection-item button { flex-shrink: 0; }
        /* Drag and drop */
        .p3xr-connection-item.cdk-drag-preview {
            box-sizing: border-box;
            border-radius: 4px;
            box-shadow: 0 5px 5px -3px rgba(0,0,0,0.2), 0 8px 10px 1px rgba(0,0,0,0.14), 0 3px 14px 2px rgba(0,0,0,0.12);
            background: var(--mat-app-background-color, #fff);
        }
        .p3xr-connection-item.cdk-drag-placeholder { opacity: 0.3; }
        .cdk-drop-list-dragging .p3xr-connection-item:not(.cdk-drag-placeholder) {
            transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
        }
        .p3xr-connection-group-block.cdk-drag-preview {
            box-sizing: border-box;
            border-radius: 4px;
            box-shadow: 0 5px 5px -3px rgba(0,0,0,0.2), 0 8px 10px 1px rgba(0,0,0,0.14), 0 3px 14px 2px rgba(0,0,0,0.12);
            background: var(--mat-app-background-color, #fff);
        }
        .p3xr-connection-group-block.cdk-drag-placeholder { opacity: 0.3; }
        .p3xr-group-drop-list.cdk-drop-list-dragging .p3xr-connection-group-block:not(.cdk-drag-placeholder) {
            transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
        }
        .p3xr-connection-group-header[cdkDragHandle] { cursor: grab; }
        /* Only tree settings rows are clickable/hoverable. License rows stay static like AngularJS. */
        .p3xr-tree-settings-list mat-list-item { cursor: pointer; }
        .p3xr-tree-settings-list mat-list-item:hover { background-color: var(--p3xr-hover-bg); }
        /* ACL users list: hoverable rows only when editable */
        .p3xr-acl-users-list .p3xr-acl-clickable { cursor: pointer; }
        .p3xr-acl-users-list .p3xr-acl-clickable:hover { background-color: var(--p3xr-hover-bg); }
        /* Settings list: bold label (left), normal value (right) */
        ::ng-deep .p3xr-tree-settings-list .mdc-list-item__primary-text {
            width: 100%;
        }
        ::ng-deep .p3xr-settings-label {
            font-weight: 500;
        }
        ::ng-deep .p3xr-settings-value {
            font-weight: 400;
            opacity: 0.8;
        }
    `],
    changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SettingsComponent implements OnInit, OnDestroy {
    private static readonly UNGROUPED_GROUP_KEY = '';
    strings;
    connectionsList: any[] = [];
    groupedConnections: Array<{ name: string; connections: any[] }> = [];
    collapsedGroups: Set<string>;
    groupModeEnabled = false;

    private static readonly COLLAPSED_GROUPS_KEY = 'p3xr-collapsed-connection-groups';
    private static readonly GROUP_MODE_KEY = 'p3xr-connection-group-mode';
    isElectron = /electron/i.test(navigator.userAgent);
    isPromoDomain = window.location.hostname === 'p3x.redis.patrikx3.com';
    readonlyConnections = false;
    currentConnectionId: string | undefined;
    aclUsers: any[] | null = null;
    aclCurrentUser = '';
    aclLoading = false;
    isXs = false;
    private electronUiStorage: Record<string, string> | null = null;

    private readonly unsubs: Array<() => void> = [];

    constructor(
        @Inject(I18nService) private i18n: I18nService,
        @Inject(SettingsService) public settings: SettingsService,
        @Inject(RedisStateService) private state: RedisStateService,
        @Inject(CommonService) private common: CommonService,
        @Inject(SocketService) private socket: SocketService,
        @Inject(MainCommandService) private cmd: MainCommandService,
        @Inject(ConnectionDialogService) private connectionDialog: ConnectionDialogService,
        @Inject(TreecontrolSettingsDialogService) private treeSettingsDialog: TreecontrolSettingsDialogService,
        @Inject(AiSettingsDialogService) private aiSettingsDialog: AiSettingsDialogService,
        @Inject(AclUserDialogService) private aclUserDialog: AclUserDialogService,
        @Inject(BreakpointObserver) private breakpointObserver: BreakpointObserver,
        @Inject(ChangeDetectorRef) private cdr: ChangeDetectorRef,
        @Inject(NotificationService) public notificationService: NotificationService,
    ) {
        this.strings = this.i18n.strings;

        this.restoreGroupingState();

        this.breakpointObserver.observe('(max-width: 599px)').subscribe(result => {
            this.isXs = result.matches;
            this.cdr.markForCheck();
        });
    }

    ngOnInit(): void {
        this.refreshState();

        // Subscribe to socket events for reactive updates
        const sub1 = this.socket.connections$.subscribe(() => this.refreshState());
        const sub2 = this.socket.configuration$.subscribe(() => this.refreshState());
        const sub3 = this.socket.stateChanged$.subscribe(() => this.refreshState());
        const sub4 = this.socket.redisStatus$.subscribe(() => this.refreshState());
        this.unsubs.push(() => { sub1.unsubscribe(); sub2.unsubscribe(); sub3.unsubscribe(); sub4.unsubscribe(); });
    }

    ngOnDestroy(): void {
        this.unsubs.forEach(fn => fn());
    }

    get currentConnectionName(): string {
        const conn = this.connectionsList.find((c: any) => c.id === this.currentConnectionId);
        return conn?.name || '';
    }

    private refreshState(): void {
        this.connectionsList = this.state.connections()?.list || [];
        this.readonlyConnections = this.state.cfg()?.readonlyConnections === true;
        const prevConnId = this.currentConnectionId;
        this.currentConnectionId = this.state.connection()?.id;
        // Auto-load ACL when connection changes
        if (this.currentConnectionId && this.currentConnectionId !== prevConnId) {
            this.loadAclUsers();
        } else if (!this.currentConnectionId && prevConnId) {
            this.aclUsers = null;
            this.aclCurrentUser = '';
        }
        this.buildGroupedConnections();
        this.cdr.detectChanges();
    }

    toggleGroupMode(): void {
        this.groupModeEnabled = !this.groupModeEnabled;
        this.setPersistentItem(SettingsComponent.GROUP_MODE_KEY, String(this.groupModeEnabled));
    }

    toggleGroup(name: string): void {
        if (this.collapsedGroups.has(name)) {
            this.collapsedGroups.delete(name);
        } else {
            this.collapsedGroups.add(name);
        }
        this.setPersistentItem(SettingsComponent.COLLAPSED_GROUPS_KEY, JSON.stringify([...this.collapsedGroups]));
    }

    private restoreGroupingState(): void {
        this.groupModeEnabled = this.getPersistentItem(SettingsComponent.GROUP_MODE_KEY) === 'true';
        // Sync bootstrap value to localStorage so React can read it (shared origin in Electron)
        this.setPersistentItem(SettingsComponent.GROUP_MODE_KEY, String(this.groupModeEnabled));
        try {
            const stored = this.getPersistentItem(SettingsComponent.COLLAPSED_GROUPS_KEY);
            this.collapsedGroups = stored
                ? new Set(JSON.parse(stored).map((name: string) => this.normalizeCollapsedGroupName(name)))
                : new Set();
        } catch {
            this.collapsedGroups = new Set();
        }
    }

    private getPersistentItem(key: string): string | null {
        const value = this.getElectronUiStorage()[key];
        if (typeof value === 'string') {
            return value;
        }

        try {
            return localStorage.getItem(key);
        } catch {
            return null;
        }
    }

    private setPersistentItem(key: string, value: string): void {
        try {
            localStorage.setItem(key, value);
        } catch { /* ignore */ }

        const storage = this.getElectronUiStorage();
        storage[key] = value;
        this.electronUiStorage = storage;

        try {
            if (window.parent && window.parent !== window) {
                window.parent.postMessage({ type: 'p3x-ui-storage-set', key, value }, '*');
            }
        } catch { /* ignore */ }
    }

    private getElectronUiStorage(): Record<string, string> {
        if (this.electronUiStorage !== null) {
            return this.electronUiStorage;
        }

        // Read from __p3xr_electron_bootstrap which was captured in main.js
        // BEFORE Angular's router stripped the query params.
        let storage: Record<string, string> = {};
        try {
            const bootstrap = (globalThis as any).__p3xr_electron_bootstrap;
            if (bootstrap && typeof bootstrap === 'object' && !Array.isArray(bootstrap)) {
                storage = this.normalizeElectronUiStorage(bootstrap);
            }
        } catch {
            storage = {};
        }

        this.electronUiStorage = storage;
        return storage;
    }

    private normalizeElectronUiStorage(value: unknown): Record<string, string> {
        if (!value || typeof value !== 'object' || Array.isArray(value)) {
            return {};
        }

        return Object.entries(value).reduce((result: Record<string, string>, [key, entryValue]) => {
            if (typeof entryValue === 'string') {
                result[key] = entryValue;
            }
            return result;
        }, {});
    }

    getGroupDisplayName(name: string): string {
        return name === SettingsComponent.UNGROUPED_GROUP_KEY
            ? this.getUngroupedLabel()
            : name;
    }

    private getUngroupedLabel(): string {
        return this.strings().label?.ungrouped;
    }

    private normalizeCollapsedGroupName(name: unknown): string {
        if (typeof name !== 'string') {
            return '';
        }

        return this.isLegacyUngroupedGroupName(name)
            ? SettingsComponent.UNGROUPED_GROUP_KEY
            : name;
    }

    private isLegacyUngroupedGroupName(name: string): boolean {
        return name === 'Ungrouped' || name === this.getUngroupedLabel();
    }

    private buildGroupedConnections(): void {
        // Use a Map to preserve the order groups first appear in the connections list.
        // This respects the server-persisted order (including after drag reorder).
        const groups = new Map<string, any[]>();
        for (const conn of this.connectionsList) {
            const groupName = conn.group?.trim() || SettingsComponent.UNGROUPED_GROUP_KEY;
            if (!groups.has(groupName)) {
                groups.set(groupName, []);
            }
            groups.get(groupName)!.push(conn);
        }
        const result: Array<{ name: string; connections: any[] }> = [];
        for (const [name, connections] of groups) {
            result.push({ name, connections });
        }
        this.groupedConnections = result;
    }

    // Predicates prevent items from entering the wrong drop list level
    groupDropPredicate = (drag: any) => drag.data && 'connections' in drag.data;
    connectionDropPredicate = (drag: any) => drag.data && !('connections' in drag.data);

    async dropGroup(event: CdkDragDrop<any[]>): Promise<void> {
        if (event.previousIndex === event.currentIndex) return;
        moveItemInArray(this.groupedConnections, event.previousIndex, event.currentIndex);
        // Rebuild flat list in new group order and persist
        const allIds: string[] = [];
        for (const group of this.groupedConnections) {
            for (const conn of group.connections) {
                allIds.push(conn.id);
            }
        }
        try {
            await this.socket.request({
                action: 'connection/reorder',
                payload: { ids: allIds },
            });
        } catch (e) {
            this.common.generalHandleError(e);
            this.refreshState();
        }
    }

    async dropConnection(event: CdkDragDrop<any[]>, groupName: string): Promise<void> {
        if (event.previousIndex === event.currentIndex) return;
        moveItemInArray(event.container.data, event.previousIndex, event.currentIndex);
        // Persist the new order to the server
        try {
            const reorderedIds = event.container.data.map((c: any) => c.id);
            await this.socket.request({
                action: 'connection/reorder',
                payload: { group: groupName || undefined, ids: reorderedIds },
            });
        } catch (e) {
            this.common.generalHandleError(e);
            this.refreshState();
        }
    }

    async dropUngroupedConnection(event: CdkDragDrop<any[]>): Promise<void> {
        if (event.previousIndex === event.currentIndex) return;
        moveItemInArray(this.connectionsList, event.previousIndex, event.currentIndex);
        try {
            const reorderedIds = this.connectionsList.map((c: any) => c.id);
            await this.socket.request({
                action: 'connection/reorder',
                payload: { ids: reorderedIds },
            });
        } catch (e) {
            this.common.generalHandleError(e);
            this.refreshState();
        }
    }

    // --- Connections ---

    connectionForm(type: string, model?: any): void {
        this.connectionDialog.show({ type: type as any, model, $event: undefined });
    }

    async connect(connection: any): Promise<void> {
        this.cmd.connectRequest$.next({ connection, disableState: true });
    }

    async disconnect(): Promise<void> {
        await this.cmd.disconnect();
        this.refreshState();
    }

    async deleteConnection(connection: any, $event: any): Promise<void> {
        try {
            await this.common.confirm({
                event: $event,
                message: this.strings().confirm?.deleteConnectionText,
            });
            await this.socket.request({
                action: 'connection/delete',
                payload: { id: connection.id },
            });
            this.common.toast(this.strings().status?.deleted);
        } catch (e) {
            if (e !== undefined) {
                this.common.generalHandleError(e);
            }
        }
    }

    getConnectionClients(connection: any): { key: string; clients: number }[] {
        const redisConnections = this.state.redisConnections() || {};
        const results: { key: string; clients: number }[] = [];
        for (const key of Object.keys(redisConnections)) {
            if (redisConnections[key].connection?.name === connection.name) {
                results.push({ key, clients: redisConnections[key].clients?.length || 0 });
            }
        }
        return results;
    }

    // --- AI Settings ---

    isAiEnabled(): boolean {
        return this.state.cfg()?.aiEnabled !== false;
    }

    async toggleAiEnabled(enabled: boolean): Promise<void> {
        try {
            await this.socket.request({
                action: 'ai/set-groq-api-key',
                payload: {
                    aiEnabled: enabled,
                },
            });
            const cfg = { ...this.state.cfg(), aiEnabled: enabled };
            this.state.cfg.set(cfg);
            this.cdr.markForCheck();
        } catch (e) {
            this.common.generalHandleError(e);
        }
    }

    hasGroqApiKey(): boolean {
        return this.state.cfg()?.hasGroqApiKey === true;
    }

    isUseOwnKey(): boolean {
        return this.state.cfg()?.aiUseOwnKey === true && this.hasGroqApiKey();
    }

    async toggleUseOwnKey(useOwn: boolean): Promise<void> {
        if (useOwn && !this.hasGroqApiKey()) {
            return;
        }
        try {
            await this.socket.request({
                action: 'ai/set-groq-api-key',
                payload: {
                    aiEnabled: this.state.cfg()?.aiEnabled !== false,
                    aiUseOwnKey: useOwn,
                },
            });
            const cfg = { ...this.state.cfg(), aiUseOwnKey: useOwn };
            this.state.cfg.set(cfg);
            this.cdr.markForCheck();
        } catch (e) {
            this.common.generalHandleError(e);
        }
    }

    isAiReadonly(): boolean {
        return this.readonlyConnections || this.state.cfg()?.groqApiKeyReadonly === true;
    }

    isGroqApiKeyReadonly(): boolean {
        return this.state.cfg()?.groqApiKeyReadonly === true;
    }

    async openAiSettings($event: any): Promise<void> {
        await this.aiSettingsDialog.show();
        this.cdr.markForCheck();
    }

    // --- Tree Settings ---

    openTreeSettings($event: any): void {
        this.treeSettingsDialog.show({ $event });
    }

    // --- ACL Management ---

    async loadAclUsers(): Promise<void> {
        this.aclLoading = true;
        this.cdr.markForCheck();
        try {
            const resp = await this.socket.request({ action: 'acl/list' });
            this.aclUsers = resp.data.users;
            this.aclCurrentUser = resp.data.currentUser;
        } catch {
            this.aclUsers = null;
        }
        this.aclLoading = false;
        this.cdr.markForCheck();
    }

    async deleteAclUser(username: string): Promise<void> {
        try {
            const msg = (this.strings().page?.acl?.confirmDelete) + ` "${username}"?`;
            await this.common.confirm({ message: msg });
            await this.socket.request({ action: 'acl/del-user', payload: { username } });
            this.common.toast({ message: this.strings().page?.acl?.userDeleted });
            this.loadAclUsers();
        } catch {}
    }

    async openAclCreate(): Promise<void> {
        const result = await this.aclUserDialog.show({
            username: '',
            rules: 'on >password +@all ~* &*',
            isNew: true,
        });
        if (result) {
            try {
                await this.socket.request({ action: 'acl/set-user', payload: { username: result.username, rules: result.rules } });
                this.common.toast({ message: this.strings().page?.acl?.userSaved });
                this.loadAclUsers();
            } catch (e) {
                this.common.generalHandleError(e);
            }
        }
    }

    async openAclEdit(user: any): Promise<void> {
        const parts = user.raw.split(' ');
        const result = await this.aclUserDialog.show({
            username: user.name,
            rules: parts.slice(2).join(' '),
            isNew: false,
        });
        if (result) {
            try {
                await this.socket.request({ action: 'acl/set-user', payload: { username: result.username, rules: result.rules } });
                this.common.toast({ message: this.strings().page?.acl?.userSaved });
                this.loadAclUsers();
            } catch (e) {
                this.common.generalHandleError(e);
            }
        }
    }

    // --- GUI Framework Switch ---

    switchToReact(): void {
        switchGui('react');
    }

    switchToVue(): void {
        switchGui('vue');
    }
}