RSS Git Download  Clone
Raw Blame History 33kB 630 lines
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useDisplay } from 'vuetify'
import { useI18nStore } from '../../stores/i18n'
import { useRedisStateStore } from '../../stores/redis-state'
import { useSettingsStore } from '../../stores/settings'
import { useCommonStore } from '../../stores/common.store'
import { useOverlayStore } from '../../stores/overlay.store'
import { useMainCommandStore } from '../../stores/main-command'
import { request, onSocketEvent } from '../../stores/socket.service'
import { getPersistentItem, setPersistentItem } from '../../stores/electron-bridge'
import { isNotificationsEnabled, setNotificationsEnabled } from '../../stores/notification'
import draggable from 'vuedraggable'
import P3xrAccordion from '../../components/P3xrAccordion.vue'
import P3xrButton from '../../components/P3xrButton.vue'
import ConnectionDialog from '../../dialogs/ConnectionDialog.vue'
import AiSettingsDialog from '../../dialogs/AiSettingsDialog.vue'
import AclUserDialog from '../../dialogs/AclUserDialog.vue'
import TreeSettingsDialog from '../../dialogs/TreeSettingsDialog.vue'

function openLink(url: string) {
    globalThis.open(url, '_blank')
}

// @ts-ignore
import { switchGui } from '../../../core/gui-switch'

const i18n = useI18nStore()
const strings = computed(() => i18n.strings)
const isPromoDomain = window.location.hostname === 'p3x.redis.patrikx3.com'
const state = useRedisStateStore()
const settings = useSettingsStore()
const common = useCommonStore()
const overlay = useOverlayStore()
const mainCommand = useMainCommandStore()
const { width } = useDisplay()
const isXs = computed(() => width.value < 600)

const connectionsList = computed(() => state.connections?.list ?? [])
const readonlyConnections = computed(() => state.cfg?.readonlyConnections === true)
const currentConnectionId = computed(() => state.connection?.id)

// Force re-render on socket events
const tick = ref(0)
const unsubs: (() => void)[] = []
onMounted(() => {
    unsubs.push(
        onSocketEvent('connections', () => tick.value++),
        onSocketEvent('configuration', () => tick.value++),
        onSocketEvent('redis-status', () => tick.value++),
    )
})
onUnmounted(() => unsubs.forEach(fn => fn()))

// Group mode
const groupModeEnabled = ref(getPersistentItem('p3xr-connection-group-mode') === 'true')
const collapsedGroups = ref<Set<string>>((() => {
    try {
        const stored = getPersistentItem('p3xr-collapsed-connection-groups')
        return stored ? new Set(JSON.parse(stored)) : new Set()
    } catch { return new Set() }
})())

function toggleGroupMode() {
    groupModeEnabled.value = !groupModeEnabled.value
    setPersistentItem('p3xr-connection-group-mode', String(groupModeEnabled.value))
}

function toggleGroup(name: string) {
    const s = collapsedGroups.value
    s.has(name) ? s.delete(name) : s.add(name)
    collapsedGroups.value = new Set(s)
    setPersistentItem('p3xr-collapsed-connection-groups', JSON.stringify([...s]))
}

const groupedConnections = computed(() => {
    void tick.value
    const groups = new Map<string, any[]>()
    for (const conn of connectionsList.value) {
        const name = conn.group?.trim() || ''
        if (!groups.has(name)) groups.set(name, [])
        groups.get(name)!.push(conn)
    }
    return Array.from(groups, ([name, conns]) => ({ name, connections: conns }))
})

function getConnectionClients(conn: any): string {
    const results: string[] = []
    for (const key of Object.keys(state.redisConnections || {})) {
        if (state.redisConnections[key]?.connection?.name === conn.name) {
            const fn = strings.value?.page?.overview?.connectedCount
            const clients = state.redisConnections[key].clients?.length || 0
            results.push(typeof fn === 'function' ? fn({ length: clients }) : '')
        }
    }
    return results.join(' ')
}

// --- Drag & drop handlers ---
function arrayMove<T>(arr: T[], oldIndex: number, newIndex: number): T[] {
    const result = [...arr]
    const [item] = result.splice(oldIndex, 1)
    result.splice(newIndex, 0, item)
    return result
}

async function handleDragEndFlat(evt: any) {
    const { oldIndex, newIndex } = evt
    if (oldIndex === newIndex) return
    const reordered = arrayMove(connectionsList.value, oldIndex, newIndex)
    try {
        await request({ action: 'connection/reorder', payload: { ids: reordered.map((c: any) => c.id) } })
    } catch (e) { common.generalHandleError(e) }
}

async function handleDragEndGroupReorder(evt: any) {
    const { oldIndex, newIndex } = evt
    if (oldIndex === newIndex) return
    const reordered = arrayMove(groupedConnections.value, oldIndex, newIndex)
    const allIds: string[] = []
    for (const group of reordered) {
        for (const conn of group.connections) {
            allIds.push(conn.id)
        }
    }
    try {
        await request({ action: 'connection/reorder', payload: { ids: allIds } })
    } catch (e) { common.generalHandleError(e) }
}

async function handleDragEndWithinGroup(groupName: string, evt: any) {
    const { oldIndex, newIndex } = evt
    if (oldIndex === newIndex) return
    const group = groupedConnections.value.find(g => g.name === groupName)
    if (!group) return
    const reordered = arrayMove(group.connections, oldIndex, newIndex)
    try {
        await request({ action: 'connection/reorder', payload: { group: groupName || undefined, ids: reordered.map((c: any) => c.id) } })
    } catch (e) { common.generalHandleError(e) }
}

// Dialog states
const dialogOpen = ref(false)
const dialogType = ref<'new' | 'edit'>('new')
const dialogModel = ref<any>(undefined)
const aiDialogOpen = ref(false)
const treeDialogOpen = ref(false)
const notifToggle = ref(isNotificationsEnabled())
const aclUsers = ref<any[] | null>(null)
const aclCurrentUser = ref('')
const aclLoading = ref(false)
const aclEditOpen = ref(false)
const aclEditUsername = ref('')
const aclEditRules = ref('')
const aclEditIsNew = ref(false)
const currentConnectionName = computed(() => connectionsList.value.find((c: any) => c.id === currentConnectionId.value)?.name || '')

async function loadAclUsers() {
    aclLoading.value = true
    try {
        const resp = await request({ action: 'acl/list' })
        aclUsers.value = resp.data.users
        aclCurrentUser.value = resp.data.currentUser
    } catch {
        aclUsers.value = null
    }
    aclLoading.value = false
}

// Auto-load ACL when connection changes
watch(currentConnectionId, (newId, oldId) => {
    if (newId && newId !== oldId) { loadAclUsers() }
    else if (!newId) { aclUsers.value = null; aclCurrentUser.value = '' }
}, { immediate: true })

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

function openAclCreate() {
    aclEditIsNew.value = true
    aclEditUsername.value = ''
    aclEditRules.value = 'on >password +@all ~* &*'
    aclEditOpen.value = true
}

function openAclEdit(user: any) {
    aclEditIsNew.value = false
    aclEditUsername.value = user.name
    aclEditRules.value = user.raw.split(' ').slice(2).join(' ')
    aclEditOpen.value = true
}

async function handleAclDialogClose(result?: { username: string; rules: string[] }) {
    aclEditOpen.value = false
    if (result) {
        try {
            await request({ action: 'acl/set-user', payload: { username: result.username, rules: result.rules } })
            common.toast(strings.value?.page?.acl?.userSaved)
            loadAclUsers()
        } catch (e) { common.generalHandleError(e) }
    }
}

function handleConnectionForm(formType: 'new' | 'edit', formModel?: any) {
    dialogType.value = formType
    dialogModel.value = formModel
    dialogOpen.value = true
}

// Connect
const handleConnect = (conn: any) => mainCommand.connect(conn)

async function handleDelete(conn: any) {
    try {
        await common.confirm({ message: strings.value?.confirm?.deleteConnectionText })
        await request({ action: 'connection/delete', payload: { id: conn.id } })
        common.toast(strings.value?.status?.deleted)
    } catch (e: any) {
        if (e !== undefined) common.generalHandleError(e)
    }
}

// AI settings
const isAiEnabled = computed(() => state.cfg?.aiEnabled !== false)
const hasGroqApiKey = computed(() => state.cfg?.hasGroqApiKey === true)
const isUseOwnKey = computed(() => state.cfg?.aiUseOwnKey === true && hasGroqApiKey.value)
const isAiReadonly = computed(() => readonlyConnections.value || state.cfg?.groqApiKeyReadonly === true)

async function toggleAiEnabled(enabled: boolean) {
    try {
        await request({ action: 'ai/set-groq-api-key', payload: { aiEnabled: enabled } })
        state.cfg = { ...state.cfg, aiEnabled: enabled }
    } catch (e) { common.generalHandleError(e) }
}

async function toggleUseOwnKey(useOwn: boolean) {
    if (useOwn && !hasGroqApiKey.value) return
    try {
        await request({ action: 'ai/set-groq-api-key', payload: { aiEnabled: isAiEnabled.value, aiUseOwnKey: useOwn } })
        state.cfg = { ...state.cfg, aiUseOwnKey: useOwn }
    } catch (e) { common.generalHandleError(e) }
}

// Tree settings items
const treeSettingsItems = computed(() => [
    { label: strings.value?.form?.treeSettings?.field?.treeSeparator, value: settings.redisTreeDivider || strings.value?.label?.treeSeparatorEmptyNote },
    { label: strings.value?.form?.treeSettings?.field?.page, value: settings.pageCount, hint: strings.value?.form?.treeSettings?.error?.page },
    { label: strings.value?.form?.treeSettings?.field?.keyPageCount, value: settings.keyPageCount, hint: strings.value?.form?.treeSettings?.error?.keyPageCount },
    { label: strings.value?.form?.treeSettings?.maxValueDisplay, value: settings.maxValueDisplay, hint: strings.value?.form?.treeSettings?.maxValueDisplayInfo },
    { label: strings.value?.form?.treeSettings?.maxKeys, value: settings.maxKeys, hint: strings.value?.form?.treeSettings?.maxKeysInfo },
    { label: strings.value?.form?.treeSettings?.field?.keysSort, value: settings.keysSort ? strings.value?.label?.keysSort?.on : strings.value?.label?.keysSort?.off },
    { label: strings.value?.form?.treeSettings?.field?.searchMode, value: settings.searchClientSide ? strings.value?.form?.treeSettings?.label?.searchModeClient : strings.value?.form?.treeSettings?.label?.searchModeServer },
    { label: strings.value?.form?.treeSettings?.field?.searchModeStartsWith, value: settings.searchStartsWith ? strings.value?.form?.treeSettings?.label?.searchModeStartsWith : strings.value?.form?.treeSettings?.label?.searchModeIncludes },
    { label: null, value: settings.jsonFormat === 2 ? strings.value?.form?.treeSettings?.label?.jsonFormatTwoSpace : strings.value?.form?.treeSettings?.label?.jsonFormatFourSpace },
    { label: null, value: settings.animation ? strings.value?.form?.treeSettings?.label?.animation : strings.value?.form?.treeSettings?.label?.noAnimation },
    { label: null, value: settings.undoEnabled ? strings.value?.form?.treeSettings?.label?.undoEnabled : strings.value?.form?.treeSettings?.label?.undoDisabled, hint: strings.value?.form?.treeSettings?.undoHint },
    { label: null, value: settings.showDiffBeforeSave ? strings.value?.form?.treeSettings?.label?.diffEnabled : strings.value?.form?.treeSettings?.label?.diffDisabled },
])
</script>

<template>
    <!-- Donate -->
    <P3xrAccordion :title="strings?.title?.donateTitle" accordion-key="donate" :collapsible="false">
        <template #actions>
            <P3xrButton :label="`${strings?.title?.donate} — PayPal`" icon="mdi-heart" @click="openLink('https://www.paypal.me/patrikx3')" />
        </template>
        <div style="padding: 12px 16px; font-size: 13px; opacity: 0.85; line-height: 1.6;">
            {{ strings?.title?.donateDescription }}
        </div>
    </P3xrAccordion>

    <!-- Promo: AI Network Assistant (demo site only) -->
    <template v-if="isPromoDomain">
        <br />

        <P3xrAccordion :title="strings?.promo?.title" accordion-key="promo-network" :collapsible="false">
            <template #actions>
                <P3xrButton :label="strings?.promo?.visit" icon="mdi-earth-arrow-right" @click="openLink('https://network.corifeus.com')" />
            </template>
            <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>
        </P3xrAccordion>
    </template>

    <br />

    <!-- Connections -->
    <P3xrAccordion :title="strings?.label?.connections" accordion-key="settings">
        <template #actions>
            <P3xrButton :label="strings?.label?.grouped" :icon="groupModeEnabled ? 'mdi-checkbox-marked' : 'mdi-checkbox-blank-outline'" @click="toggleGroupMode" />
            <P3xrButton v-if="!readonlyConnections" :label="strings?.intention?.connectionAdd" icon="mdi-plus-box" @click="handleConnectionForm('new')" />
        </template>

        <div v-if="connectionsList.length === 0" style="padding: 16px;">
            {{ strings?.intention?.noConnectionsInSettings }}
        </div>

        <!-- Grouped mode with drag & drop -->
        <draggable v-if="connectionsList.length > 0 && groupModeEnabled"
            :model-value="groupedConnections" item-key="name"
            :animation="150" handle=".p3xr-group-header"
            @end="handleDragEndGroupReorder">
            <template #item="{ element: group }">
                <div>
                    <div class="p3xr-group-header" style="cursor: grab;" @click="toggleGroup(group.name)">
                        <v-icon size="18">{{ collapsedGroups.has(group.name) ? 'mdi-chevron-right' : 'mdi-chevron-down' }}</v-icon>
                        <span>{{ group.name || strings?.label?.ungrouped }}</span>
                        <span style="opacity: 0.5; font-weight: 400; font-size: 12px;">({{ group.connections.length }})</span>
                    </div>
                    <template v-if="!collapsedGroups.has(group.name)">
                        <draggable :model-value="group.connections" item-key="id"
                            :animation="150" handle=".p3xr-connection-row"
                            @end="(evt: any) => handleDragEndWithinGroup(group.name, evt)">
                            <template #item="{ element: conn, index: i }">
                                <div>
                                    <div class="p3xr-connection-row" style="cursor: grab;">
                                        <div style="flex: 1; min-width: 0; overflow: hidden;">
                                            <div style="font-weight: 700;">{{ conn.name }}</div>
                                            <div style="font-size: 13px; opacity: 0.7;">{{ conn.host }}:{{ conn.port }}</div>
                                            <div style="font-size: 13px; opacity: 0.7;">{{ getConnectionClients(conn) }}</div>
                                        </div>
                                        <v-btn v-if="currentConnectionId !== conn.id" variant="flat" color="secondary" size="small" @click="handleConnect(conn)" :style="isXs ? 'min-width:40px;width:40px;height:40px;padding:0;' : 'min-width:auto;padding:0 8px;'">
                                            <v-icon>mdi-power</v-icon><span v-if="!isXs" style="margin-left:3px;">{{ strings?.intention?.connect }}</span>
                                            <v-tooltip v-if="isXs" activator="parent" location="top">{{ strings?.intention?.connect }}</v-tooltip>
                                        </v-btn>
                                        <v-btn v-else variant="flat" color="secondary" size="small" @click="mainCommand.disconnect()" :style="isXs ? 'min-width:40px;width:40px;height:40px;padding:0;' : 'min-width:auto;padding:0 8px;'">
                                            <i class="fa fa-power-off" /><span v-if="!isXs" style="margin-left:3px;">{{ strings?.intention?.disconnect }}</span>
                                            <v-tooltip v-if="isXs" activator="parent" location="top">{{ strings?.intention?.disconnect }}</v-tooltip>
                                        </v-btn>
                                        <template v-if="!readonlyConnections">
                                            <v-btn variant="flat" color="error" size="small" @click="handleDelete(conn)" :style="isXs ? 'min-width:40px;width:40px;height:40px;padding:0;' : 'min-width:auto;padding:0 8px;'">
                                                <v-icon>mdi-delete-forever</v-icon><span v-if="!isXs" style="margin-left:3px;">{{ strings?.intention?.delete }}</span>
                                                <v-tooltip v-if="isXs" activator="parent" location="top">{{ strings?.intention?.delete }}</v-tooltip>
                                            </v-btn>
                                            <v-btn variant="flat" color="primary" size="small" @click="handleConnectionForm('edit', conn)" :style="isXs ? 'min-width:40px;width:40px;height:40px;padding:0;' : 'min-width:auto;padding:0 8px;'">
                                                <v-icon>mdi-pencil</v-icon><span v-if="!isXs" style="margin-left:3px;">{{ strings?.intention?.edit }}</span>
                                                <v-tooltip v-if="isXs" activator="parent" location="top">{{ strings?.intention?.edit }}</v-tooltip>
                                            </v-btn>
                                        </template>
                                        <v-btn v-if="readonlyConnections" variant="flat" color="primary" size="small" @click="handleConnectionForm('edit', conn)" :style="isXs ? 'min-width:40px;width:40px;height:40px;padding:0;' : 'min-width:auto;padding:0 8px;'">
                                            <v-icon>mdi-comment-text</v-icon><span v-if="!isXs" style="margin-left:3px;">{{ strings?.intention?.view }}</span>
                                            <v-tooltip v-if="isXs" activator="parent" location="top">{{ strings?.intention?.view }}</v-tooltip>
                                        </v-btn>
                                    </div>
                                    <v-divider v-if="i < group.connections.length - 1" />
                                </div>
                            </template>
                        </draggable>
                    </template>
                </div>
            </template>
        </draggable>

        <!-- Flat mode with drag & drop -->
        <draggable v-if="connectionsList.length > 0 && !groupModeEnabled"
            :model-value="connectionsList" item-key="id"
            :animation="150" handle=".p3xr-connection-row"
            @end="handleDragEndFlat">
            <template #item="{ element: conn, index: i }">
                <div>
                    <div class="p3xr-connection-row" style="cursor: grab;">
                        <div style="flex: 1; min-width: 0; overflow: hidden;">
                            <div style="font-weight: 700;">{{ conn.name }}</div>
                            <div style="font-size: 13px; opacity: 0.7;">{{ conn.host }}:{{ conn.port }}</div>
                            <div style="font-size: 13px; opacity: 0.7;">{{ getConnectionClients(conn) }}</div>
                        </div>
                        <v-btn v-if="currentConnectionId !== conn.id" variant="flat" color="secondary" size="small" @click="handleConnect(conn)" :style="isXs ? 'min-width:40px;width:40px;height:40px;padding:0;' : 'min-width:auto;padding:0 8px;'">
                            <v-icon>mdi-power</v-icon><span v-if="!isXs" style="margin-left:3px;">{{ strings?.intention?.connect }}</span>
                            <v-tooltip v-if="isXs" activator="parent" location="top">{{ strings?.intention?.connect }}</v-tooltip>
                        </v-btn>
                        <v-btn v-else variant="flat" color="secondary" size="small" @click="mainCommand.disconnect()" :style="isXs ? 'min-width:40px;width:40px;height:40px;padding:0;' : 'min-width:auto;padding:0 8px;'">
                            <i class="fa fa-power-off" /><span v-if="!isXs" style="margin-left:3px;">{{ strings?.intention?.disconnect }}</span>
                            <v-tooltip v-if="isXs" activator="parent" location="top">{{ strings?.intention?.disconnect }}</v-tooltip>
                        </v-btn>
                        <template v-if="!readonlyConnections">
                            <v-btn variant="flat" color="error" size="small" @click="handleDelete(conn)" :style="isXs ? 'min-width:40px;width:40px;height:40px;padding:0;' : 'min-width:auto;padding:0 8px;'">
                                <v-icon>mdi-delete-forever</v-icon><span v-if="!isXs" style="margin-left:3px;">{{ strings?.intention?.delete }}</span>
                                <v-tooltip v-if="isXs" activator="parent" location="top">{{ strings?.intention?.delete }}</v-tooltip>
                            </v-btn>
                            <v-btn variant="flat" color="primary" size="small" @click="handleConnectionForm('edit', conn)" :style="isXs ? 'min-width:40px;width:40px;height:40px;padding:0;' : 'min-width:auto;padding:0 8px;'">
                                <v-icon>mdi-pencil</v-icon><span v-if="!isXs" style="margin-left:3px;">{{ strings?.intention?.edit }}</span>
                                <v-tooltip v-if="isXs" activator="parent" location="top">{{ strings?.intention?.edit }}</v-tooltip>
                            </v-btn>
                        </template>
                        <v-btn v-if="readonlyConnections" variant="flat" color="primary" size="small" @click="handleConnectionForm('edit', conn)" :style="isXs ? 'min-width:40px;width:40px;height:40px;padding:0;' : 'min-width:auto;padding:0 8px;'">
                            <v-icon>mdi-comment-text</v-icon><span v-if="!isXs" style="margin-left:3px;">{{ strings?.intention?.view }}</span>
                            <v-tooltip v-if="isXs" activator="parent" location="top">{{ strings?.intention?.view }}</v-tooltip>
                        </v-btn>
                    </div>
                    <v-divider v-if="i < connectionsList.length - 1" />
                </div>
            </template>
        </draggable>
    </P3xrAccordion>

    <template v-if="currentConnectionId">
        <br />
        <!-- ACL Users -->
        <P3xrAccordion :title="`${strings?.page?.acl?.title} — ${currentConnectionName}`" accordion-key="acl-users">
            <template #actions>
                <P3xrButton @click="loadAclUsers(); $event.stopPropagation()" :label="strings?.intention?.refresh" icon="mdi-refresh" />
                <P3xrButton v-if="!readonlyConnections" @click="openAclCreate(); $event.stopPropagation()" :label="strings?.page?.acl?.createUser" icon="mdi-account-plus" />
            </template>
            <div v-if="aclLoading" style="padding: 16px; opacity: 0.6;">
                {{ strings?.page?.acl?.loading }}
            </div>
            <div v-else-if="!aclUsers" style="padding: 16px; opacity: 0.6;">
                {{ strings?.page?.acl?.noUsers }}
            </div>
            <div v-else>
                <template v-for="(user, idx) in aclUsers" :key="user.name">
                    <div :class="{ 'p3xr-settings-list-item': !readonlyConnections }" style="display: flex; align-items: center; gap: 4px; padding: 8px 8px 8px 16px; min-height: 56px;" @click="!readonlyConnections && openAclEdit(user)">
                        <div style="flex: 1; min-width: 0; display: flex; align-items: center;">
                            <span style="font-weight: 700;">{{ user.name }}</span>
                            <span v-if="user.name === aclCurrentUser" style="opacity: 0.5; margin-left: 6px; font-size: 11px; line-height: 1;">({{ strings?.page?.acl?.currentUser }})</span>
                        </div>
                        <v-tooltip v-if="!user.enabled" :text="strings?.page?.acl?.disabled" location="top">
                            <template #activator="{ props: tp }">
                                <v-icon v-bind="tp" color="warning" size="20">mdi-alert</v-icon>
                            </template>
                        </v-tooltip>
                        <template v-if="!readonlyConnections">
                            <v-btn v-if="user.name !== 'default' && user.name !== aclCurrentUser"
                                variant="flat" color="error" size="small"
                                @click.stop="deleteAclUser(user.name)"
                                :style="isXs ? 'min-width:40px;width:40px;height:40px;padding:0;' : 'min-width:auto;padding:0 8px;'">
                                <v-icon>mdi-delete</v-icon><span v-if="!isXs" style="margin-left:3px;">{{ strings?.page?.acl?.deleteUser }}</span>
                                <v-tooltip v-if="isXs" activator="parent" location="top">{{ strings?.page?.acl?.deleteUser }}</v-tooltip>
                            </v-btn>
                            <v-btn variant="flat" color="primary" size="small"
                                @click.stop="openAclEdit(user)"
                                :style="isXs ? 'min-width:40px;width:40px;height:40px;padding:0;' : 'min-width:auto;padding:0 8px;'">
                                <v-icon>mdi-pencil</v-icon><span v-if="!isXs" style="margin-left:3px;">{{ strings?.page?.acl?.editUser }}</span>
                                <v-tooltip v-if="isXs" activator="parent" location="top">{{ strings?.page?.acl?.editUser }}</v-tooltip>
                            </v-btn>
                        </template>
                    </div>
                    <v-divider v-if="idx < aclUsers.length - 1" />
                </template>
            </div>
        </P3xrAccordion>
        <AclUserDialog
            :open="aclEditOpen"
            :username="aclEditUsername"
            :rules="aclEditRules"
            :is-new="aclEditIsNew"
            @close="handleAclDialogClose"
        />
    </template>

    <br />

    <!-- GUI Framework -->
    <P3xrAccordion title="GUI" accordion-key="gui-framework">
        <div style="display: flex; justify-content: flex-end; padding: 16px;">
            <div class="p3xr-gui-toggle">
                <span class="p3xr-gui-toggle-item" @click="switchGui('ng')"><i class="fab fa-angular" style="color:#dd0031;margin-right:6px;font-size:28px;line-height:1;"></i>Angular</span>
                <span class="p3xr-gui-toggle-item" @click="switchGui('react')"><i class="fab fa-react" style="color:#61dafb;margin-right:6px;font-size:24px;line-height:1;"></i>React</span>
                <span class="p3xr-gui-toggle-active"><i class="fab fa-vuejs" style="color:#42b883;margin-right:6px;font-size:26px;line-height:1;"></i>Vue</span>
            </div>
        </div>
    </P3xrAccordion>

    <br />

    <!-- AI Settings -->
    <P3xrAccordion :title="strings?.label?.aiSettings" accordion-key="ai-settings">
        <template #actions>
            <P3xrButton v-if="!readonlyConnections && !state.cfg?.groqApiKeyReadonly" :label="strings?.intention?.edit" icon="mdi-pencil" @click="aiDialogOpen = true" />
        </template>
        <v-list density="compact" class="pa-0">
            <v-list-item>
                <div style="display: flex; width: 100%; align-items: center;">
                    <div style="flex: 1; font-weight: 500;">{{ strings?.label?.aiEnabled }}</div>
                    <div style="padding-right: 8px;"><v-switch :model-value="isAiEnabled" :disabled="isAiReadonly" @update:model-value="toggleAiEnabled" density="comfortable" hide-details /></div>
                </div>
            </v-list-item>
            <template v-if="isAiEnabled && hasGroqApiKey">
                <v-divider />
                <v-list-item>
                    <div style="width: 100%;">
                        <div style="display: flex; align-items: center;">
                            <div style="flex: 1; font-weight: 500;">{{ strings?.label?.aiRouteViaNetwork }}</div>
                            <div style="padding-right: 8px;"><v-switch :model-value="!isUseOwnKey" :disabled="isAiReadonly" @update:model-value="(v: any) => toggleUseOwnKey(!v)" density="comfortable" hide-details /></div>
                        </div>
                        <div style="font-size: 12px; opacity: 0.7;">
                            {{ isUseOwnKey ? strings?.label?.aiRoutingDirect : strings?.label?.aiRoutingNetwork }}
                            <a v-if="!isUseOwnKey" href="https://console.groq.com" target="_blank" rel="noreferrer" style="color: inherit; text-decoration: underline;">console.groq.com</a>
                        </div>
                    </div>
                </v-list-item>
                <v-divider />
                <v-list-item>
                    <div style="display: flex; width: 100%;">
                        <div style="flex: 1; font-weight: 500;">{{ strings?.label?.aiGroqApiKey }}</div>
                        <div style="font-family: monospace;">{{ state.cfg?.groqApiKeyMasked }}</div>
                    </div>
                </v-list-item>
            </template>
        </v-list>
    </P3xrAccordion>

    <br />

    <!-- Desktop Notifications -->
    <P3xrAccordion :title="strings?.label?.desktopNotifications" accordion-key="desktop-notifications">
        <v-list density="compact" class="pa-0">
            <v-list-item>
                <div style="display: flex; width: 100%; align-items: center;">
                    <div style="flex: 1; font-weight: 500;">{{ strings?.label?.desktopNotificationsEnabled }}</div>
                    <div style="padding-right: 8px;"><v-switch :model-value="notifToggle" @update:model-value="(v: any) => { setNotificationsEnabled(v); notifToggle = v }" density="comfortable" hide-details /></div>
                </div>
            </v-list-item>
            <v-divider />
            <v-list-item>
                <div style="font-size: 12px; opacity: 0.7;">{{ strings?.label?.desktopNotificationsInfo }}</div>
            </v-list-item>
        </v-list>
    </P3xrAccordion>

    <br />

    <!-- Tree/Redis Settings -->
    <P3xrAccordion :title="strings?.form?.treeSettings?.label?.formName" accordion-key="tree-settings">
        <template #actions>
            <P3xrButton :label="strings?.intention?.edit" icon="mdi-pencil" @click="treeDialogOpen = true" />
        </template>
        <v-list density="compact" class="pa-0">
            <template v-for="(item, i) in treeSettingsItems" :key="i">
                <v-list-item class="p3xr-settings-list-item" @click="treeDialogOpen = true">
                    <div style="width: 100%;">
                        <div style="display: flex; width: 100%;">
                            <div v-if="item.label" style="flex: 1; font-weight: 500;">{{ item.label }}</div>
                            <div :style="{ fontWeight: item.label ? 400 : 500, opacity: item.label ? 0.8 : 1 }">{{ String(item.value) }}</div>
                        </div>
                        <div v-if="item.hint" style="font-size: 12px; opacity: 0.7;">{{ item.hint }}</div>
                    </div>
                </v-list-item>
                <v-divider v-if="i < treeSettingsItems.length - 1" />
            </template>
        </v-list>
    </P3xrAccordion>

    <div style="height: 8px;" />

    <!-- Dialogs -->
    <ConnectionDialog :open="dialogOpen" :type="dialogType" :source-model="dialogModel" @close="dialogOpen = false" />
    <AiSettingsDialog :open="aiDialogOpen" @close="aiDialogOpen = false" />
    <TreeSettingsDialog :open="treeDialogOpen" @close="treeDialogOpen = false" />
</template>

<style scoped>
.p3xr-connection-row {
    display: flex;
    align-items: center;
    gap: 4px;
    padding: 8px 8px 8px 16px;
    min-height: 56px;
    box-sizing: border-box;
}
.p3xr-group-header {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 8px 16px;
    cursor: pointer;
    font-weight: 700;
    font-size: 13px;
    user-select: none;
    opacity: 0.8;
    background: rgba(128, 128, 128, 0.05);
    border-bottom: 1px solid rgba(128, 128, 128, 0.1);
}
.p3xr-group-header:hover {
    opacity: 1;
    background: rgba(128, 128, 128, 0.1);
}
.p3xr-gui-toggle {
    display: inline-flex;
    border-radius: 4px;
    overflow: hidden;
    border: 1px solid rgba(128, 128, 128, 0.3);
}
.p3xr-gui-toggle-active,
.p3xr-gui-toggle-item {
    padding: 8px 12px;
    font-size: 14px;
    user-select: none;
    display: inline-flex;
    align-items: center;
    height: 40px;
    box-sizing: border-box;
}
.p3xr-gui-toggle-active {
    font-weight: 700;
    background: rgb(var(--v-theme-primary));
    color: rgb(var(--v-theme-on-primary));
}
.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 {
    font-weight: 500;
    cursor: pointer;
}
.p3xr-gui-toggle-item:hover {
    background: rgba(128, 128, 128, 0.1);
}
</style>

<style>
.p3xr-settings-list-item {
    cursor: pointer;
}
.p3xr-settings-list-item:hover {
    background: rgba(128, 128, 128, 0.08);
}
</style>