Add CLI popup and integrate with Spotlight Search for enhanced user interaction
- Introduced a new CLI popup component accessible via keyboard shortcuts (Cmd+Shift+` / Ctrl+Shift+`). - Updated SpotlightSearch component to include CLI option in search results, allowing users to open the CLI directly. - Added CLI entry in helpTree for improved discoverability of the CLI feature. - Enhanced Dashboard.vue with an App Switcher for better navigation and user experience.
This commit is contained in:
parent
a1282f7e68
commit
59210a7927
@ -9,6 +9,9 @@
|
|||||||
<!-- Spotlight command palette (Cmd+K / Ctrl+K) -->
|
<!-- Spotlight command palette (Cmd+K / Ctrl+K) -->
|
||||||
<SpotlightSearch />
|
<SpotlightSearch />
|
||||||
|
|
||||||
|
<!-- CLI popup (Cmd+Shift+` / Ctrl+Shift+`) -->
|
||||||
|
<CLIPopup />
|
||||||
|
|
||||||
<!-- App launcher overlay (iframe popup) -->
|
<!-- App launcher overlay (iframe popup) -->
|
||||||
<AppLauncherOverlay />
|
<AppLauncherOverlay />
|
||||||
|
|
||||||
@ -59,11 +62,13 @@ import { useRouter, useRoute } from 'vue-router'
|
|||||||
import SplashScreen from './components/SplashScreen.vue'
|
import SplashScreen from './components/SplashScreen.vue'
|
||||||
import PWAUpdatePrompt from './components/PWAUpdatePrompt.vue'
|
import PWAUpdatePrompt from './components/PWAUpdatePrompt.vue'
|
||||||
import SpotlightSearch from './components/SpotlightSearch.vue'
|
import SpotlightSearch from './components/SpotlightSearch.vue'
|
||||||
|
import CLIPopup from './components/CLIPopup.vue'
|
||||||
import AppLauncherOverlay from './components/AppLauncherOverlay.vue'
|
import AppLauncherOverlay from './components/AppLauncherOverlay.vue'
|
||||||
import Screensaver from './components/Screensaver.vue'
|
import Screensaver from './components/Screensaver.vue'
|
||||||
import HelpGuideModal from './components/HelpGuideModal.vue'
|
import HelpGuideModal from './components/HelpGuideModal.vue'
|
||||||
import { useControllerNav } from '@/composables/useControllerNav'
|
import { useControllerNav } from '@/composables/useControllerNav'
|
||||||
import { useSpotlightStore } from '@/stores/spotlight'
|
import { useSpotlightStore } from '@/stores/spotlight'
|
||||||
|
import { useCLIStore } from '@/stores/cli'
|
||||||
import { useMessageToast } from '@/composables/useMessageToast'
|
import { useMessageToast } from '@/composables/useMessageToast'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import { useScreensaverStore } from '@/stores/screensaver'
|
import { useScreensaverStore } from '@/stores/screensaver'
|
||||||
@ -71,6 +76,7 @@ import { useScreensaverStore } from '@/stores/screensaver'
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const screensaverStore = useScreensaverStore()
|
const screensaverStore = useScreensaverStore()
|
||||||
const spotlightStore = useSpotlightStore()
|
const spotlightStore = useSpotlightStore()
|
||||||
|
const cliStore = useCLIStore()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
const messageToast = useMessageToast()
|
const messageToast = useMessageToast()
|
||||||
const toastMessage = messageToast.toastMessage
|
const toastMessage = messageToast.toastMessage
|
||||||
@ -108,6 +114,12 @@ function onKeyDown(e: KeyboardEvent) {
|
|||||||
spotlightStore.toggle()
|
spotlightStore.toggle()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Cmd+Shift+` / Ctrl+Shift+` or plain C - CLI popup
|
||||||
|
if ((mod && e.shiftKey && e.key === '`') || ((e.key === 'c' || e.key === 'C') && !isInput)) {
|
||||||
|
e.preventDefault()
|
||||||
|
cliStore.toggle()
|
||||||
|
return
|
||||||
|
}
|
||||||
// 's' key activates screensaver when authenticated (skip if typing in input)
|
// 's' key activates screensaver when authenticated (skip if typing in input)
|
||||||
if (e.key === 's' || e.key === 'S') {
|
if (e.key === 's' || e.key === 'S') {
|
||||||
const target = e.target as HTMLElement
|
const target = e.target as HTMLElement
|
||||||
|
|||||||
97
neode-ui/src/components/AppSwitcher.vue
Normal file
97
neode-ui/src/components/AppSwitcher.vue
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative" ref="containerRef">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-2 px-2 py-1.5 rounded-lg text-white/90 hover:bg-white/10 hover:text-white transition-colors min-w-0"
|
||||||
|
@click="showDropdown = !showDropdown"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/assets/img/logo-archipelago.svg"
|
||||||
|
alt="Archipelago"
|
||||||
|
class="w-6 h-6 shrink-0 object-contain"
|
||||||
|
/>
|
||||||
|
<span class="text-sm font-medium truncate max-w-[120px] sm:max-w-[140px]">Archipelago CLI</span>
|
||||||
|
<svg class="w-4 h-4 text-white/50 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Transition name="dropdown">
|
||||||
|
<div
|
||||||
|
v-if="showDropdown"
|
||||||
|
class="absolute right-0 top-full mt-1 py-1 min-w-[160px] rounded-lg glass-card shadow-xl z-50"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full flex items-center gap-2 px-3 py-2 text-left text-sm transition-colors"
|
||||||
|
:class="inCLI ? 'bg-white/10 text-white' : 'text-white/80 hover:bg-white/10 hover:text-white'"
|
||||||
|
@click="selectCLI"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
Archipelago CLI
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full flex items-center gap-2 px-3 py-2 text-left text-sm transition-colors"
|
||||||
|
:class="!inCLI ? 'bg-white/10 text-white' : 'text-white/80 hover:bg-white/10 hover:text-white'"
|
||||||
|
@click="selectWebUI"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
Web UI
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import { useCLIStore } from '@/stores/cli'
|
||||||
|
|
||||||
|
const cliStore = useCLIStore()
|
||||||
|
const containerRef = ref<HTMLElement | null>(null)
|
||||||
|
const showDropdown = ref(false)
|
||||||
|
|
||||||
|
const inCLI = computed(() => cliStore.isOpen)
|
||||||
|
|
||||||
|
function selectCLI() {
|
||||||
|
showDropdown.value = false
|
||||||
|
cliStore.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectWebUI() {
|
||||||
|
showDropdown.value = false
|
||||||
|
cliStore.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (containerRef.value && !containerRef.value.contains(e.target as Node)) {
|
||||||
|
showDropdown.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('click', handleClickOutside)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('click', handleClickOutside)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dropdown-enter-active,
|
||||||
|
.dropdown-leave-active {
|
||||||
|
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||||
|
}
|
||||||
|
.dropdown-enter-from,
|
||||||
|
.dropdown-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
300
neode-ui/src/components/CLIPopup.vue
Normal file
300
neode-ui/src/components/CLIPopup.vue
Normal file
@ -0,0 +1,300 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="cli-popup">
|
||||||
|
<div
|
||||||
|
v-if="cliStore.isOpen"
|
||||||
|
class="fixed inset-0 z-[2500] flex items-center justify-center p-4"
|
||||||
|
@click.self="cliStore.close()"
|
||||||
|
>
|
||||||
|
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
||||||
|
<div
|
||||||
|
ref="panelRef"
|
||||||
|
class="glass-card w-full max-w-2xl relative z-10 overflow-hidden flex flex-col"
|
||||||
|
:style="panelStyle"
|
||||||
|
@mousedown="onPanelMouseDown"
|
||||||
|
>
|
||||||
|
<!-- Header: terminal icon + title + app switcher -->
|
||||||
|
<div class="flex items-center gap-3 px-4 py-3 border-b border-white/10">
|
||||||
|
<div
|
||||||
|
ref="dragHandleRef"
|
||||||
|
class="flex items-center justify-center w-8 h-8 rounded cursor-grab hover:bg-white/10 transition-colors shrink-0"
|
||||||
|
:class="{ 'cursor-grabbing': isDragging }"
|
||||||
|
title="Drag to move"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4 text-white/50" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M8 6h2v2H8V6zm0 5h2v2H8v-2zm0 5h2v2H8v-2zm5-10h2v2h-2V6zm0 5h2v2h-2v-2zm0 5h2v2h-2v-2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<svg class="w-5 h-5 text-white/60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-white font-medium">CLI Access</span>
|
||||||
|
</div>
|
||||||
|
<AppSwitcher />
|
||||||
|
<kbd class="hidden sm:inline-flex px-2 py-1 text-xs text-white/50 bg-white/10 rounded">Esc</kbd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content: mock CLI in dev, SSH instructions in production -->
|
||||||
|
<div class="flex-1 overflow-hidden flex flex-col min-h-0">
|
||||||
|
<!-- Mock CLI interface (dev mode only) -->
|
||||||
|
<div
|
||||||
|
v-if="isDev"
|
||||||
|
class="flex-1 flex flex-col min-h-0 p-4 bg-black/80 rounded-b-lg font-mono text-sm"
|
||||||
|
>
|
||||||
|
<div ref="outputRef" class="flex-1 overflow-y-auto text-green-400/90 whitespace-pre-wrap break-words mb-2 min-h-0">{{ mockOutput }}</div>
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
<span class="text-amber-400">archipelago@node</span>
|
||||||
|
<span class="text-white/60">~</span>
|
||||||
|
<span class="text-white/40">$</span>
|
||||||
|
<input
|
||||||
|
ref="cliInputRef"
|
||||||
|
v-model="mockCommand"
|
||||||
|
type="text"
|
||||||
|
class="flex-1 bg-transparent text-white outline-none border-none"
|
||||||
|
placeholder=" "
|
||||||
|
@keydown.enter="runMockCommand"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SSH instructions (production only) -->
|
||||||
|
<div v-else class="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
|
<p class="text-white/80 text-sm">
|
||||||
|
Connect to this node via SSH to access the command line. Use the same host as this web interface.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="p-3 rounded-lg bg-white/5 font-mono text-sm">
|
||||||
|
<div class="text-white/50 text-xs uppercase tracking-wider mb-2">SSH Command</div>
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<code class="text-green-400 break-all">{{ sshCommand }}</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 px-2 py-1 rounded bg-white/10 text-white/80 hover:bg-white/20 hover:text-white text-xs transition-colors"
|
||||||
|
@click="copyCommand"
|
||||||
|
>
|
||||||
|
{{ copied ? 'Copied!' : 'Copy' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-3 rounded-lg bg-white/5 text-sm space-y-1">
|
||||||
|
<div class="text-white/50 text-xs uppercase tracking-wider mb-2">Connection Details</div>
|
||||||
|
<div class="flex flex-col gap-1.5 text-white/80">
|
||||||
|
<div class="flex justify-between gap-4">
|
||||||
|
<span class="text-white/50">Host</span>
|
||||||
|
<span class="font-mono text-green-400">{{ host }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-4">
|
||||||
|
<span class="text-white/50">User</span>
|
||||||
|
<span class="font-mono">archipelago</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-4">
|
||||||
|
<span class="text-white/50">Password</span>
|
||||||
|
<span class="font-mono">archipelago</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-white/50 text-xs">
|
||||||
|
From the terminal menu you can install to disk, configure Bitcoin, Lightning, view logs, and more.
|
||||||
|
</p>
|
||||||
|
<p class="text-white/40 text-xs">
|
||||||
|
Tip: Press <kbd class="px-1.5 py-0.5 rounded bg-white/10 font-mono text-[10px]">C</kbd> or <kbd class="px-1.5 py-0.5 rounded bg-white/10 font-mono text-[10px]">⌘⇧`</kbd> to open this anytime.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import { useCLIStore } from '@/stores/cli'
|
||||||
|
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||||
|
import AppSwitcher from '@/components/AppSwitcher.vue'
|
||||||
|
|
||||||
|
const cliStore = useCLIStore()
|
||||||
|
const panelRef = ref<HTMLElement | null>(null)
|
||||||
|
const dragHandleRef = ref<HTMLElement | null>(null)
|
||||||
|
const outputRef = ref<HTMLElement | null>(null)
|
||||||
|
const cliInputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
const copied = ref(false)
|
||||||
|
|
||||||
|
const mockCommand = ref('')
|
||||||
|
const mockOutput = ref(` ╔═══════════════════════════════════════════════════════════╗
|
||||||
|
║ 🏝️ ARCHIPELAGO BITCOIN NODE OS ║
|
||||||
|
║ Your sovereign Bitcoin infrastructure ║
|
||||||
|
╚═══════════════════════════════════════════════════════════╝
|
||||||
|
|
||||||
|
System Status:
|
||||||
|
─────────────────────────────────────────────────────────────
|
||||||
|
Mode: 🟢 Installed
|
||||||
|
Podman: 🟢 Installed
|
||||||
|
Bitcoin: 🟢 Running (blocks: syncing)
|
||||||
|
Lightning: 🟡 Stopped
|
||||||
|
|
||||||
|
Main Menu:
|
||||||
|
─────────────────────────────────────────────────────────────
|
||||||
|
r) Refresh - Update IP/status
|
||||||
|
w) Open Web UI - Launch graphical interface
|
||||||
|
1) Install to Disk - Permanently install Archipelago
|
||||||
|
2) Setup Bitcoin Core - Configure Bitcoin full node
|
||||||
|
3) Setup Lightning (LND) - Configure Lightning Network
|
||||||
|
4) Setup BTCPay Server - Bitcoin payment processor
|
||||||
|
5) View Logs - Monitor running services
|
||||||
|
6) Network Settings - Configure networking
|
||||||
|
7) System Info - View system information
|
||||||
|
q) Quit
|
||||||
|
|
||||||
|
`)
|
||||||
|
|
||||||
|
const isDragging = ref(false)
|
||||||
|
const dragStart = ref<{ x: number; y: number; panelX: number; panelY: number } | null>(null)
|
||||||
|
|
||||||
|
const SAVED_POSITION_KEY = 'archipelago-cli-position'
|
||||||
|
const savedPosition = ref<{ x: number; y: number } | null>(null)
|
||||||
|
|
||||||
|
const isDev = import.meta.env.DEV
|
||||||
|
const host = computed(() => window.location.hostname)
|
||||||
|
const sshCommand = computed(() => `ssh archipelago@${host.value}`)
|
||||||
|
|
||||||
|
const panelStyle = computed(() => {
|
||||||
|
const pos = savedPosition.value
|
||||||
|
if (!pos) return {}
|
||||||
|
return {
|
||||||
|
transform: `translate(${pos.x}px, ${pos.y}px)`,
|
||||||
|
margin: 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function loadSavedPosition() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(SAVED_POSITION_KEY)
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
savedPosition.value = { x: parsed.x ?? 0, y: parsed.y ?? 0 }
|
||||||
|
} else {
|
||||||
|
savedPosition.value = null
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
savedPosition.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePosition(x: number, y: number) {
|
||||||
|
savedPosition.value = { x, y }
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SAVED_POSITION_KEY, JSON.stringify({ x, y }))
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runMockCommand() {
|
||||||
|
const cmd = mockCommand.value.trim()
|
||||||
|
if (!cmd) return
|
||||||
|
mockOutput.value += `\n archipelago@node ~ $ ${cmd}\n`
|
||||||
|
const lower = cmd.toLowerCase()
|
||||||
|
if (lower === 'r' || lower === 'refresh') {
|
||||||
|
mockOutput.value += ` Status refreshed.\n`
|
||||||
|
} else if (lower === 'w' || lower.startsWith('web')) {
|
||||||
|
mockOutput.value += ` Opening Web UI... (press C to return to CLI)\n`
|
||||||
|
} else if (lower === 'q' || lower === 'quit' || lower === 'exit') {
|
||||||
|
mockOutput.value += ` Goodbye! 🏝️\n`
|
||||||
|
cliStore.close()
|
||||||
|
} else if (lower === 'help' || lower === '?') {
|
||||||
|
mockOutput.value += ` Type r, w, 1-7, or q. Press C to switch to Web UI.\n`
|
||||||
|
} else {
|
||||||
|
mockOutput.value += ` Unknown command. Type 'help' or 'r' for menu.\n`
|
||||||
|
}
|
||||||
|
mockCommand.value = ''
|
||||||
|
nextTick(() => {
|
||||||
|
outputRef.value?.scrollTo({ top: outputRef.value.scrollHeight, behavior: 'smooth' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyCommand() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(sshCommand.value)
|
||||||
|
copied.value = true
|
||||||
|
setTimeout(() => {
|
||||||
|
copied.value = false
|
||||||
|
}, 2000)
|
||||||
|
} catch {
|
||||||
|
// Fallback for older browsers
|
||||||
|
const textarea = document.createElement('textarea')
|
||||||
|
textarea.value = sshCommand.value
|
||||||
|
document.body.appendChild(textarea)
|
||||||
|
textarea.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(textarea)
|
||||||
|
copied.value = true
|
||||||
|
setTimeout(() => {
|
||||||
|
copied.value = false
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPanelMouseDown(e: MouseEvent) {
|
||||||
|
if (!dragHandleRef.value?.contains(e.target as Node)) return
|
||||||
|
isDragging.value = true
|
||||||
|
const rect = panelRef.value?.getBoundingClientRect()
|
||||||
|
if (!rect) return
|
||||||
|
const currentX = savedPosition.value?.x ?? 0
|
||||||
|
const currentY = savedPosition.value?.y ?? 0
|
||||||
|
dragStart.value = { x: e.clientX, y: e.clientY, panelX: currentX, panelY: currentY }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseMove(e: MouseEvent) {
|
||||||
|
if (!dragStart.value) return
|
||||||
|
const dx = e.clientX - dragStart.value.x
|
||||||
|
const dy = e.clientY - dragStart.value.y
|
||||||
|
savePosition(dragStart.value.panelX + dx, dragStart.value.panelY + dy)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseUp() {
|
||||||
|
isDragging.value = false
|
||||||
|
dragStart.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
useModalKeyboard(panelRef, computed(() => cliStore.isOpen), () => cliStore.close())
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => cliStore.isOpen,
|
||||||
|
(open) => {
|
||||||
|
if (open) {
|
||||||
|
loadSavedPosition()
|
||||||
|
if (isDev) {
|
||||||
|
nextTick(() => cliInputRef.value?.focus())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadSavedPosition()
|
||||||
|
window.addEventListener('mousemove', onMouseMove)
|
||||||
|
window.addEventListener('mouseup', onMouseUp)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('mousemove', onMouseMove)
|
||||||
|
window.removeEventListener('mouseup', onMouseUp)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cli-popup-enter-active,
|
||||||
|
.cli-popup-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.cli-popup-enter-from,
|
||||||
|
.cli-popup-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -114,10 +114,12 @@ import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import Fuse from 'fuse.js'
|
import Fuse from 'fuse.js'
|
||||||
import { useSpotlightStore } from '@/stores/spotlight'
|
import { useSpotlightStore } from '@/stores/spotlight'
|
||||||
|
import { useCLIStore } from '@/stores/cli'
|
||||||
import { helpTree, flattenForSearch, type SearchableItem } from '@/data/helpTree'
|
import { helpTree, flattenForSearch, type SearchableItem } from '@/data/helpTree'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const spotlightStore = useSpotlightStore()
|
const spotlightStore = useSpotlightStore()
|
||||||
|
const cliStore = useCLIStore()
|
||||||
|
|
||||||
const inputRef = ref<HTMLInputElement | null>(null)
|
const inputRef = ref<HTMLInputElement | null>(null)
|
||||||
const panelRef = ref<HTMLElement | null>(null)
|
const panelRef = ref<HTMLElement | null>(null)
|
||||||
@ -208,7 +210,9 @@ function selectItem(item: SearchableItem) {
|
|||||||
type: item.type,
|
type: item.type,
|
||||||
})
|
})
|
||||||
spotlightStore.close()
|
spotlightStore.close()
|
||||||
if (item.path) {
|
if (item.path === '__cli__') {
|
||||||
|
cliStore.open()
|
||||||
|
} else if (item.path) {
|
||||||
router.push(item.path)
|
router.push(item.path)
|
||||||
} else if (item.content) {
|
} else if (item.content) {
|
||||||
spotlightStore.showHelpModal({ title: item.label, content: item.content, relatedPath: item.relatedPath })
|
spotlightStore.showHelpModal({ title: item.label, content: item.content, relatedPath: item.relatedPath })
|
||||||
@ -224,7 +228,9 @@ function selectHelpItem(section: { id: string }, item: { id: string; label: stri
|
|||||||
type,
|
type,
|
||||||
})
|
})
|
||||||
spotlightStore.close()
|
spotlightStore.close()
|
||||||
if (item.path) {
|
if (item.path === '__cli__') {
|
||||||
|
cliStore.open()
|
||||||
|
} else if (item.path) {
|
||||||
router.push(item.path)
|
router.push(item.path)
|
||||||
} else if (item.content) {
|
} else if (item.content) {
|
||||||
spotlightStore.showHelpModal({ title: item.label, content: item.content, relatedPath: item.relatedPath })
|
spotlightStore.showHelpModal({ title: item.label, content: item.content, relatedPath: item.relatedPath })
|
||||||
@ -233,6 +239,10 @@ function selectHelpItem(section: { id: string }, item: { id: string; label: stri
|
|||||||
|
|
||||||
function selectRecent(item: { id: string; label: string; path?: string; type: 'navigate' | 'learn' | 'action' }) {
|
function selectRecent(item: { id: string; label: string; path?: string; type: 'navigate' | 'learn' | 'action' }) {
|
||||||
spotlightStore.close()
|
spotlightStore.close()
|
||||||
|
if (item.path === '__cli__') {
|
||||||
|
cliStore.open()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (item.path) {
|
if (item.path) {
|
||||||
router.push(item.path)
|
router.push(item.path)
|
||||||
return
|
return
|
||||||
|
|||||||
@ -64,6 +64,7 @@ export const helpTree: HelpSection[] = [
|
|||||||
id: 'actions',
|
id: 'actions',
|
||||||
label: 'Actions',
|
label: 'Actions',
|
||||||
items: [
|
items: [
|
||||||
|
{ id: 'open-cli', label: 'Open CLI', path: '__cli__' },
|
||||||
{ id: 'install-app', label: 'Install an App', path: '/dashboard/marketplace' },
|
{ id: 'install-app', label: 'Install an App', path: '/dashboard/marketplace' },
|
||||||
{ id: 'manage-apps', label: 'Manage My Apps', path: '/dashboard/apps' },
|
{ id: 'manage-apps', label: 'Manage My Apps', path: '/dashboard/apps' },
|
||||||
{ id: 'network-settings', label: 'Network Settings', path: '/dashboard/server' },
|
{ id: 'network-settings', label: 'Network Settings', path: '/dashboard/server' },
|
||||||
|
|||||||
25
neode-ui/src/stores/cli.ts
Normal file
25
neode-ui/src/stores/cli.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export const useCLIStore = defineStore('cli', () => {
|
||||||
|
const isOpen = ref(false)
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
isOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
isOpen.value = !isOpen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isOpen,
|
||||||
|
open,
|
||||||
|
close,
|
||||||
|
toggle,
|
||||||
|
}
|
||||||
|
})
|
||||||
@ -130,6 +130,11 @@
|
|||||||
class="flex-1 overflow-hidden relative pb-20 md:pb-0 glass-piece z-10"
|
class="flex-1 overflow-hidden relative pb-20 md:pb-0 glass-piece z-10"
|
||||||
:class="{ 'glass-throw-main': showZoomIn }"
|
:class="{ 'glass-throw-main': showZoomIn }"
|
||||||
>
|
>
|
||||||
|
<!-- App Switcher - top right, compact -->
|
||||||
|
<div class="absolute top-4 right-4 md:top-6 md:right-8 z-20">
|
||||||
|
<AppSwitcher />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Connection Status Banner -->
|
<!-- Connection Status Banner -->
|
||||||
<div v-if="isOffline && !store.isReconnecting && store.isAuthenticated" class="path-option-card mx-6 mt-6 px-6 py-3 border-l-4 border-yellow-500">
|
<div v-if="isOffline && !store.isReconnecting && store.isAuthenticated" class="path-option-card mx-6 mt-6 px-6 py-3 border-l-4 border-yellow-500">
|
||||||
<div class="flex items-center gap-2 text-yellow-200">
|
<div class="flex items-center gap-2 text-yellow-200">
|
||||||
@ -304,6 +309,7 @@ import { RouterLink, RouterView, useRouter, useRoute } from 'vue-router'
|
|||||||
import { useAppStore } from '../stores/app'
|
import { useAppStore } from '../stores/app'
|
||||||
import { useLoginTransitionStore } from '../stores/loginTransition'
|
import { useLoginTransitionStore } from '../stores/loginTransition'
|
||||||
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
||||||
|
import AppSwitcher from '@/components/AppSwitcher.vue'
|
||||||
import ControllerIndicator from '@/components/ControllerIndicator.vue'
|
import ControllerIndicator from '@/components/ControllerIndicator.vue'
|
||||||
import { playDashboardLoadOomph } from '@/composables/useLoginSounds'
|
import { playDashboardLoadOomph } from '@/composables/useLoginSounds'
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user