RSS Git Download  Clone
Raw Blame History 22kB 584 lines
import { Component, Inject, OnInit, OnDestroy, HostListener, NgZone, ChangeDetectorRef, ChangeDetectionStrategy, CUSTOM_ELEMENTS_SCHEMA, ViewEncapsulation, ViewChild, ElementRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule, Router, NavigationEnd } from '@angular/router';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { MatDividerModule } from '@angular/material/divider';
import { MatTooltipModule } from '@angular/material/tooltip';
import { BreakpointObserver } from '@angular/cdk/layout';
import { filter } from 'rxjs/operators';
declare const p3xr: any;

import { ThemeService } from '../services/theme.service';
import { I18nService } from '../services/i18n.service';
import { RedisStateService } from '../services/redis-state.service';
import { SocketService } from '../services/socket.service';
import { CommonService } from '../services/common.service';
import { NavigationService } from '../services/navigation.service';
import { AskAuthorizationDialogService } from '../dialogs/ask-authorization-dialog.service';
import { MainCommandService } from '../services/main-command.service';
import { ShortcutsService } from '../services/shortcuts.service';

// Side-effect: webpack processes the SCSS through sass-loader → css-loader → MiniCssExtractPlugin
require('./layout.component.scss');

/**
 * Angular layout component — replaces the AngularJS p3xrLayout component.
 *
 * Renders the fixed header toolbar (app name, home, settings) and fixed footer
 * toolbar (connection menu, disconnect, donate, language, theme, github).
 *
 * Electron bridge:
 *   global.p3xrSetLanguage(key) — called by webview inject script to set language
 *   global.p3xrSetMenu(route)   — called by webview inject script to navigate
 *
 * Both globals are preserved exactly as they were in the AngularJS controller
 * so existing Electron integration continues to work without any changes.
 */
@Component({
    selector: 'p3xr-layout',
    standalone: true,
    imports: [
        CommonModule,
        RouterModule,
        MatToolbarModule,
        MatButtonModule,
        MatIconModule,
        MatMenuModule,
        MatDividerModule,
        MatTooltipModule,
    ],
    schemas: [CUSTOM_ELEMENTS_SCHEMA],
    templateUrl: './layout.component.html',
    encapsulation: ViewEncapsulation.None,
    changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LayoutComponent implements OnInit, OnDestroy {

    // Header buttons: text hidden below 720px (matches AngularJS p3xr-button component)
    isWide = true;
    // Footer buttons: different AngularJS breakpoints per button
    isGtXs = true;   // >600px — Theme button text (AngularJS: hide-xs)
    isGtSm = true;   // >960px — Disconnect/Language/GitHub text (AngularJS: hide-xs hide-sm)
    isElectron = false;
    isElectronInitialized = false;

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

    constructor(
        @Inject(NgZone) private readonly ngZone: NgZone,
        @Inject(BreakpointObserver) private readonly breakpointObserver: BreakpointObserver,
        @Inject(ThemeService) readonly theme: ThemeService,
        @Inject(I18nService) readonly i18n: I18nService,
        @Inject(RedisStateService) readonly state: RedisStateService,
        @Inject(SocketService) private readonly socket: SocketService,
        @Inject(CommonService) private readonly common: CommonService,
        @Inject(AskAuthorizationDialogService) private readonly authDialog: AskAuthorizationDialogService,
        @Inject(NavigationService) private readonly nav: NavigationService,
        @Inject(Router) private readonly router: Router,
        @Inject(MainCommandService) private readonly cmd: MainCommandService,
        @Inject(ChangeDetectorRef) private readonly cdr: ChangeDetectorRef,
        @Inject(ShortcutsService) readonly shortcuts: ShortcutsService,
    ) {}

    @HostListener('document:keydown', ['$event'])
    onKeydown(event: KeyboardEvent): void {
        this.shortcuts.handleKeydown(event);
    }

    ngOnInit(): void {
        // Remove the loading splash shown before Angular bootstraps
        document.getElementById('p3xr-loading')?.remove();

        // Initialize filtered languages list
        this.filterLanguages();

        // Header: 720px (matches AngularJS p3xr-button component threshold)
        const sub720 = this.breakpointObserver.observe('(min-width: 720px)').subscribe(r => {
            this.isWide = r.matches;
            this.cdr.markForCheck();
        });
        // Footer: 600px (AngularJS hide-xs — Theme button)
        const sub600 = this.breakpointObserver.observe('(min-width: 600px)').subscribe(r => {
            this.isGtXs = r.matches;
            this.cdr.markForCheck();
        });
        // Footer: 960px (AngularJS hide-xs hide-sm — Disconnect/Language/GitHub)
        const sub960 = this.breakpointObserver.observe('(min-width: 960px)').subscribe(r => {
            this.isGtSm = r.matches;
            this.cdr.markForCheck();
        });
        this.unsubFns.push(() => { sub720.unsubscribe(); sub600.unsubscribe(); sub960.unsubscribe(); });

        this.isElectron = /electron/i.test(navigator.userAgent);

        // Auto-connect from localStorage on startup
        const savedConnection = this.readConnectionFromStorage();
        if (savedConnection) {
            this.connect(savedConnection);
        }

        // Subscribe to socket events
        this.subscribeSocketEvents();

        // Google Analytics route tracking
        this.setupRouteTracking();

        // Subscribe to connect/disconnect requests from other components
        const subConnect = this.cmd.connectRequest$.subscribe((req) => {
            this.connect(req.connection);
        });
        const subDisconnect = this.cmd.disconnectRequest$.subscribe(() => {
            this.disconnect();
        });
        this.unsubFns.push(() => { subConnect.unsubscribe(); subDisconnect.unsubscribe(); });

        // Expose Electron bridge globals with a delay so the app is fully ready.
        setTimeout(() => this.setupElectronBridge(), 3000);
    }

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

    // --- Computed properties (read by template) ---

    get connectionName(): string {
        const conn = this.state.connection();
        const strings = this.i18n.strings();
        if (conn) {
            const fn = strings?.label?.connected;
            return typeof fn === 'function' ? fn({ name: conn.name }) : (conn.name ?? '');
        }
        return strings?.intention?.connect ?? 'Connect';
    }

    readonly sortedThemeKeys = [
        'light',
        'enterprise',
        'dark',
        'darkNeu',
        'darkoBluo',
        'matrix',
        'redis',
    ];

    get themeSelectedKey(): string {
        const theme = this.theme.currentTheme();
        if (!theme.startsWith('p3xrTheme')) return '';
        const raw = theme.slice('p3xrTheme'.length);
        return raw.charAt(0).toLowerCase() + raw.slice(1);
    }

    get hasRediSearch(): boolean {
        return !!p3xr?.state?.hasRediSearch;
    }

    get reducedFunctions(): boolean {
        return !!p3xr?.state?.reducedFunctions;
    }

    get currentVersion(): string | undefined {
        return this.state.version();
    }

    get connectionsList(): any[] {
        return this.state.connections()?.list ?? [];
    }

    get groupedConnectionsList(): Array<{ name: string; connections: any[] }> {
        const list = this.connectionsList;
        let groupMode = false;
        try {
            groupMode = localStorage.getItem('p3xr-connection-group-mode') === 'true';
        } catch { /* ignore */ }
        if (!groupMode) {
            return [{ name: '', connections: list }];
        }
        const groups = new Map<string, any[]>();
        for (const conn of list) {
            const groupName = conn.group?.trim() || '';
            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 });
        }
        return result;
    }

    get currentConnection(): any {
        return this.state.connection();
    }

    @ViewChild('languageSearchInput') languageSearchInput!: ElementRef<HTMLInputElement>;
    @ViewChild('languageMenuTrigger') languageMenuTrigger!: MatMenuTrigger;

    languageSearch = '';
    filteredLanguages: string[] = [];
    highlightedLanguageIndex = 0;

    get availableLanguages(): string[] {
        return Object.keys(this.i18n.strings()?.language ?? {});
    }

    onLanguageSearchInput(value: string): void {
        this.languageSearch = value;
        this.filterLanguages();
        this.highlightedLanguageIndex = this.findCurrentLanguageIndex();
        this.cdr.markForCheck();
    }

    onLanguageMenuOpened(): void {
        this.highlightedLanguageIndex = this.findCurrentLanguageIndex();
        setTimeout(() => {
            this.languageSearchInput?.nativeElement?.focus();
            this.scrollHighlightedLanguageIntoView();
        });
    }

    private findCurrentLanguageIndex(): number {
        const idx = this.filteredLanguages.indexOf(this.i18n.currentLang());
        return idx >= 0 ? idx : 0;
    }

    onLanguageMenuClosed(): void {
        this.languageSearch = '';
        this.filterLanguages();
    }

    onLanguageSearchKeydown(event: KeyboardEvent): void {
        if (event.key === 'Escape') {
            this.languageMenuTrigger.closeMenu();
            return;
        }
        if (event.key === 'Enter') {
            event.preventDefault();
            this.onLanguageSearchEnter();
            return;
        }
        if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
            event.preventDefault();
            const len = this.filteredLanguages.length;
            if (len === 0) return;
            if (event.key === 'ArrowDown') {
                this.highlightedLanguageIndex = (this.highlightedLanguageIndex + 1) % len;
            } else {
                this.highlightedLanguageIndex = (this.highlightedLanguageIndex - 1 + len) % len;
            }
            this.scrollHighlightedLanguageIntoView();
            this.cdr.markForCheck();
            return;
        }
        event.stopPropagation();
    }

    onLanguageSearchEnter(): void {
        if (this.filteredLanguages.length > 0) {
            this.setLanguage(this.filteredLanguages[this.highlightedLanguageIndex]);
            this.languageMenuTrigger.closeMenu();
        }
    }

    private scrollHighlightedLanguageIntoView(): void {
        setTimeout(() => {
            const menu = document.querySelector('.p3xr-language-menu .mat-mdc-menu-content');
            if (!menu) return;
            const items = menu.querySelectorAll('.mat-mdc-menu-item');
            const target = items[this.highlightedLanguageIndex];
            target?.scrollIntoView({ block: 'nearest' });
        });
    }

    private filterLanguages(): void {
        const all = this.availableLanguages;
        const search = this.languageSearch.trim().toLowerCase();
        if (!search) {
            this.filteredLanguages = all;
            return;
        }
        this.filteredLanguages = all.filter(key => {
            const label = this.languageLabel(key).toLowerCase();
            return label.includes(search) || key.toLowerCase().includes(search);
        });
    }

    themeLabel(key: string): string {
        return this.i18n.strings()?.label?.theme?.[key] ?? key;
    }

    languageLabel(key: string): string {
        return this.i18n.strings()?.language?.[key] ?? key;
    }

    // --- Actions ---

    navigateTo(stateName: string, params?: any): void {
        this.nav.navigateTo(stateName, params);
    }

    reloadPage(): void {
        location.href = '/';
    }

    setTheme(key: string): void {
        this.theme.setTheme(this.theme.generateThemeName(key));
    }

    setThemeAuto(): void {
        this.theme.setTheme('auto');
    }

    async setLanguage(key: string): Promise<void> {
        try {
            // Load translation chunk before switching (lazy loading support)
            const loader = p3xr?.settings?.language?.loadTranslation;
            if (typeof loader === 'function') {
                await loader(key);
            }
            this.i18n.setLanguage(key);
            if (this.isElectron) {
                await this.socket.request({ action: 'set-language', payload: { key } });
                this.isElectronInitialized = true;
            }
            this.filterLanguages();
            this.cdr.markForCheck();
        } catch (e) {
            this.common.generalHandleError(e);
        }
    }

    async connect(connection: any): Promise<void> {
        console.time('connect');
        connection = this.cloneConnection(connection);
        try {

            const dbStorageKey = p3xr?.settings?.connection
                ?.getStorageKeyCurrentDatabase?.(connection.id);
            const db = this.getStorageString(dbStorageKey);

            if (connection.askAuth === true) {
                const auth = await this.authDialog.show();
                connection.username = auth.username || undefined;
                connection.password = auth.password || undefined;
            }

            const strings = this.i18n.strings();
            p3xr?.ui?.overlay?.show({
                message: strings?.title?.connectingRedis ?? 'Connecting...',
            });

            const response = await this.socket.request({
                action: 'connection-connect',
                payload: { connection, db },
            });

            // Update global p3xr.state
            const st = p3xr?.state;
            if (st) {
                st.page = 1;
                st.monitor = false;
                st.dbsize = response.dbsize;
                const databaseIndexes: number[] = [];
                let i = 0;
                while (i < response.databases) databaseIndexes.push(i++);
                st.databaseIndexes = databaseIndexes;
                st.connection = connection;
                st.commands = [];
                Object.keys(response.commands ?? {}).forEach(k => {
                    st.commands.push(response.commands[k][0]);
                });
                st.commands.sort();

                // Detect loaded Redis modules
                const modules = Array.isArray(response.modules) ? response.modules : [];
                st.modules = modules;
                st.hasReJSON = modules.some((m: any) => m.name === 'ReJSON');
                st.hasRediSearch = modules.some((m: any) => m.name === 'search');
                st.hasTimeSeries = modules.some((m: any) => m.name === 'timeseries');
            }

            await this.common.loadRedisInfoResponse({ response });
            this.state.syncFromGlobal();
            this.socket.stateChanged$.next();

            this.setStorageObject(
                p3xr?.settings?.connectInfo?.storageKey,
                connection,
            );

            // No navigation — just refresh the current view in place
        } catch (error) {
            this.removeStorageItem(p3xr?.settings?.connectInfo?.storageKey);
            const st = p3xr?.state;
            if (st) st.connection = undefined;
            this.state.connection.set(undefined);
            this.common.generalHandleError(error);
        } finally {
            p3xr?.ui?.overlay?.hide();
            this.cdr.markForCheck();
        }
        console.timeEnd('connect');
    }

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

    reducedFunctionality(): void {
        const strings = this.i18n.strings();
        const st = p3xr?.state;
        const fn = strings?.label?.tooManyKeys;
        const message = typeof fn === 'function'
            ? fn({
                count: st?.keysRaw?.length ?? 0,
                maxLightKeysCount: p3xr?.settings?.maxLightKeysCount ?? 0,
            })
            : '';
        this.common.confirm({ disableCancel: true, message }).catch(() => {});
    }

    openLink(target: 'github' | 'githubRelease' | 'githubChangelog' | 'donate'): void {
        const urls: Record<string, string> = {
            github: 'https://github.com/patrikx3/redis-ui',
            githubRelease: 'https://github.com/patrikx3/redis-ui/releases',
            githubChangelog: 'https://github.com/patrikx3/redis-ui/blob/master/change-log.md#change-log',
            donate: 'https://www.paypal.me/patrikx3',
        };
        window.open(urls[target], '_blank');
    }

    // --- Private helpers ---

    private cloneConnection(connection: any): any {
        return typeof p3xr?.clone === 'function'
            ? p3xr.clone(connection)
            : JSON.parse(JSON.stringify(connection));
    }

    private readConnectionFromStorage(): any {
        return this.getStorageObject(
            p3xr?.settings?.connectInfo?.storageKey,
        );
    }

    private getStorageString(name: string | undefined): string | undefined {
        if (!name) return undefined;
        try { return localStorage.getItem(name) ?? undefined; } catch { return undefined; }
    }

    private getStorageObject(name: string | undefined): any {
        const raw = this.getStorageString(name);
        if (!raw) return undefined;
        try { return JSON.parse(raw); } catch { return undefined; }
    }

    private setStorageObject(name: string | undefined, value: any): void {
        if (!name) return;
        try { localStorage.setItem(name, JSON.stringify(value)); } catch {}
    }

    private removeStorageItem(name: string | undefined): void {
        if (!name) return;
        try { localStorage.removeItem(name); } catch {}
    }

    private subscribeSocketEvents(): void {
        const sub1 = this.socket.redisDisconnected$.subscribe(() => {
            this.state.connection.set(undefined);
            this.nav.navigateTo('settings');
            this.cdr.markForCheck();
        });
        const sub2 = this.socket.socketError$.subscribe((error: any) => {
            this.removeStorageItem(p3xr?.settings?.connectInfo?.storageKey);
            this.common.generalHandleError(error);
            this.nav.navigateTo('socketio-error');
            const isHttpAuth = error?.message === 'http_auth_required' || error?.code === 'http_auth_required';
            const strings = this.i18n.strings();
            const msg = isHttpAuth ? strings.confirm?.socketioAuthRequired : strings.confirm?.socketioConnectError;
            this.common.confirm({ disableCancel: false, message: msg ?? 'Connection error' }).then(() => {
                location.reload();
            }).catch(() => {});
            this.cdr.markForCheck();
        });
        const sub3 = this.socket.connections$.subscribe(() => {
            this.state.syncFromGlobal();
            this.cdr.markForCheck();
        });
        const sub4 = this.socket.configuration$.subscribe(() => {
            this.state.syncFromGlobal();
            this.cdr.markForCheck();
        });
        const sub5 = this.socket.licenseUpdate$.subscribe(() => {
            this.state.syncFromGlobal();
            this.cdr.markForCheck();
        });
        this.unsubFns.push(
            () => { sub1.unsubscribe(); sub2.unsubscribe(); sub3.unsubscribe(); sub4.unsubscribe(); sub5.unsubscribe(); }
        );
    }

    private setupRouteTracking(): void {
        if (p3xr?.isBot?.()) return;

        const sub = this.router.events.pipe(
            filter((event): event is NavigationEnd => event instanceof NavigationEnd)
        ).subscribe((event) => {
            try {
                const path = event.urlAfterRedirects.toLowerCase().startsWith('/database/key/')
                    ? '/database/key'
                    : event.urlAfterRedirects;
                (globalThis as any).gtag?.('config',
                    p3xr?.settings?.googleAnalytics,
                    { page_path: path },
                );
            } catch { /* noop */ }
        });
        this.unsubFns.push(() => sub.unsubscribe());
    }

    /**
     * Expose the Electron bridge globals.
     *
     * Electron injects a script into the webview that calls:
     *   global.p3xrSetLanguage(key)  — sets the UI language
     *   global.p3xrSetMenu(route)    — navigates to a route
     *
     * These are the SAME globals as the AngularJS controller exposed.
     * Keeping them with the same names and behaviour ensures no changes are
     * needed in the Electron host application.
     */
    private setupElectronBridge(): void {
        if (!this.isElectron) return;

        // Listen for postMessage from the Electron shell (iframe parent).
        window.addEventListener('message', (event: MessageEvent) => {
            const data = event.data;
            if (!data || typeof data.type !== 'string') return;
            if (data.type === 'p3x-set-language' && typeof data.translation === 'string') {
                this.ngZone.run(async () => {
                    try {
                        await this.setLanguage(data.translation);
                    } catch (e) {
                        console.warn('[LayoutComponent] p3x-set-language failed', e);
                    }
                });
            } else if (data.type === 'p3x-menu' && typeof data.action === 'string') {
                this.ngZone.run(() => {
                    try {
                        this.nav.navigateTo(data.action);
                    } catch (e) {
                        console.warn('[LayoutComponent] p3x-menu failed', e);
                    }
                });
            }
        });
    }
}