feat: Archipelago demo stack (lightweight)
This commit is contained in:
227
neode-ui/src/App.vue
Normal file
227
neode-ui/src/App.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<!-- Splash Screen (only on first visit) -->
|
||||
<SplashScreen v-if="showSplash" @complete="handleSplashComplete" />
|
||||
|
||||
<!-- Main App Content - only show after splash and routing is complete -->
|
||||
<RouterView v-if="!showSplash && isReady" />
|
||||
|
||||
<!-- Spotlight command palette (Cmd+K / Ctrl+K) -->
|
||||
<SpotlightSearch />
|
||||
|
||||
<!-- CLI popup (F key) -->
|
||||
<CLIPopup />
|
||||
|
||||
<!-- App launcher overlay (iframe popup) -->
|
||||
<AppLauncherOverlay />
|
||||
|
||||
<!-- Global toast notifications -->
|
||||
<ToastStack />
|
||||
|
||||
<!-- Screensaver -->
|
||||
<Screensaver />
|
||||
|
||||
<!-- Help guide modal (from spotlight) -->
|
||||
<HelpGuideModal
|
||||
:show="spotlightStore.helpModal.show"
|
||||
:title="spotlightStore.helpModal.title"
|
||||
:content="spotlightStore.helpModal.content"
|
||||
:related-path="spotlightStore.helpModal.relatedPath"
|
||||
@close="spotlightStore.closeHelpModal()"
|
||||
/>
|
||||
|
||||
<!-- PWA Update Prompt -->
|
||||
<PWAUpdatePrompt />
|
||||
|
||||
<!-- PWA Install Prompt (Install app, not just Add to Home Screen) -->
|
||||
<PWAInstallPrompt />
|
||||
|
||||
<!-- Toast notifications - top right, glass style, any page -->
|
||||
<Teleport to="body">
|
||||
<Transition name="toast">
|
||||
<div
|
||||
v-if="toastMessage.show"
|
||||
@click="messageToast.dismissToastAndOpenMessages"
|
||||
class="fixed top-20 right-4 left-4 z-[100] w-auto max-w-md cursor-pointer rounded-xl p-4 transition-all hover:border-white/30 hover:shadow-2xl md:top-6 md:right-6 md:left-auto md:max-w-md toast-glass"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-orange-500/20">
|
||||
<svg class="h-5 w-5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-white">New message</p>
|
||||
<p class="mt-0.5 text-sm text-white/70 line-clamp-2">{{ toastMessage.text }}</p>
|
||||
<p class="mt-1 text-xs text-orange-400">Click to view</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import SplashScreen from './components/SplashScreen.vue'
|
||||
import PWAUpdatePrompt from './components/PWAUpdatePrompt.vue'
|
||||
import PWAInstallPrompt from './components/PWAInstallPrompt.vue'
|
||||
import SpotlightSearch from './components/SpotlightSearch.vue'
|
||||
import CLIPopup from './components/CLIPopup.vue'
|
||||
import AppLauncherOverlay from './components/AppLauncherOverlay.vue'
|
||||
import ToastStack from './components/ToastStack.vue'
|
||||
import Screensaver from './components/Screensaver.vue'
|
||||
import HelpGuideModal from './components/HelpGuideModal.vue'
|
||||
import { useControllerNav } from '@/composables/useControllerNav'
|
||||
import { playKeyboardTypingSound } from '@/composables/useLoginSounds'
|
||||
import { useSpotlightStore } from '@/stores/spotlight'
|
||||
import { useCLIStore } from '@/stores/cli'
|
||||
import { useMessageToast } from '@/composables/useMessageToast'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useScreensaverStore } from '@/stores/screensaver'
|
||||
import { useUIModeStore } from '@/stores/uiMode'
|
||||
|
||||
const router = useRouter()
|
||||
const screensaverStore = useScreensaverStore()
|
||||
const spotlightStore = useSpotlightStore()
|
||||
const cliStore = useCLIStore()
|
||||
const appStore = useAppStore()
|
||||
const uiModeStore = useUIModeStore()
|
||||
const messageToast = useMessageToast()
|
||||
const toastMessage = messageToast.toastMessage
|
||||
|
||||
useControllerNav()
|
||||
|
||||
// Start/stop message polling when auth state changes
|
||||
watch(() => appStore.isAuthenticated, (authenticated) => {
|
||||
if (authenticated) {
|
||||
messageToast.startPolling()
|
||||
screensaverStore.resetInactivityTimer()
|
||||
} else {
|
||||
messageToast.stopPolling()
|
||||
toastMessage.value = { show: false, text: '' }
|
||||
screensaverStore.clearInactivityTimer()
|
||||
screensaverStore.deactivate()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Reset screensaver inactivity on user activity (when authenticated)
|
||||
function onUserActivity() {
|
||||
if (appStore.isAuthenticated && !screensaverStore.isActive) {
|
||||
screensaverStore.resetInactivityTimer()
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
const isMac = navigator.platform.toUpperCase().includes('MAC')
|
||||
const mod = isMac ? e.metaKey : e.ctrlKey
|
||||
// Cmd+K / Ctrl+K only (modifier required - avoids accidental trigger when typing)
|
||||
const target = e.target as HTMLElement
|
||||
const isInput = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable
|
||||
if (mod && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
spotlightStore.toggle()
|
||||
return
|
||||
}
|
||||
// F key - CLI popup (skip when in input or modifier held)
|
||||
if ((e.key === 'f' || e.key === 'F') && !isInput && !mod && !e.altKey) {
|
||||
e.preventDefault()
|
||||
cliStore.toggle()
|
||||
return
|
||||
}
|
||||
// Cmd+1/2/3 - switch UI mode (skip when in input)
|
||||
if (mod && !isInput && appStore.isAuthenticated) {
|
||||
if (e.key === '1') { e.preventDefault(); uiModeStore.setMode('easy'); router.push('/dashboard'); return }
|
||||
if (e.key === '2') { e.preventDefault(); uiModeStore.setMode('gamer'); router.push('/dashboard'); return }
|
||||
if (e.key === '3') { e.preventDefault(); router.push('/dashboard/chat'); return }
|
||||
}
|
||||
// Cmd+M / Ctrl+M - cycle UI mode (skip when in input)
|
||||
if (mod && (e.key === 'm' || e.key === 'M') && !isInput && appStore.isAuthenticated) {
|
||||
e.preventDefault()
|
||||
uiModeStore.cycleMode()
|
||||
router.push('/dashboard')
|
||||
return
|
||||
}
|
||||
// 's' key activates screensaver when authenticated (skip if typing in input)
|
||||
if (e.key === 's' || e.key === 'S') {
|
||||
if (!isInput && appStore.isAuthenticated && !screensaverStore.isActive) {
|
||||
e.preventDefault()
|
||||
screensaverStore.activate()
|
||||
}
|
||||
}
|
||||
// Keyboard typing sound - plays on any character typed in inputs (global)
|
||||
if (isInput && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
playKeyboardTypingSound()
|
||||
}
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const showSplash = ref(true)
|
||||
const isReady = ref(false)
|
||||
|
||||
/**
|
||||
* Determine if splash screen should be shown
|
||||
* Splash is skipped if:
|
||||
* - User has already seen the intro
|
||||
* - User is on a direct route (refresh/bookmark)
|
||||
*/
|
||||
onMounted(async () => {
|
||||
window.addEventListener('keydown', onKeyDown, true)
|
||||
window.addEventListener('mousemove', onUserActivity)
|
||||
window.addEventListener('mousedown', onUserActivity)
|
||||
window.addEventListener('keydown', onUserActivity)
|
||||
window.addEventListener('touchstart', onUserActivity)
|
||||
const seenIntro = localStorage.getItem('neode_intro_seen') === '1'
|
||||
const isDirectRoute = route.path !== '/'
|
||||
|
||||
if (seenIntro || isDirectRoute) {
|
||||
showSplash.value = false
|
||||
document.body.classList.add('splash-complete')
|
||||
// Wait for router to finish initial navigation before showing content (fixes hard refresh)
|
||||
await router.isReady()
|
||||
isReady.value = true
|
||||
}
|
||||
// If splash should show, wait for it to complete
|
||||
// SplashScreen will emit 'complete' which calls handleSplashComplete
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onKeyDown, true)
|
||||
window.removeEventListener('mousemove', onUserActivity)
|
||||
window.removeEventListener('mousedown', onUserActivity)
|
||||
window.removeEventListener('keydown', onUserActivity)
|
||||
window.removeEventListener('touchstart', onUserActivity)
|
||||
})
|
||||
|
||||
/**
|
||||
* Handle splash screen completion
|
||||
* Routes user directly to appropriate screen based on onboarding status (from backend)
|
||||
*/
|
||||
async function handleSplashComplete() {
|
||||
showSplash.value = false
|
||||
document.body.classList.add('splash-complete')
|
||||
isReady.value = true
|
||||
sessionStorage.setItem('archipelago_from_splash', '1')
|
||||
|
||||
const devMode = import.meta.env.VITE_DEV_MODE
|
||||
if (devMode === 'setup' || devMode === 'existing') {
|
||||
router.push('/login').catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { isOnboardingComplete } = await import('@/composables/useOnboarding')
|
||||
const seenOnboarding = await isOnboardingComplete()
|
||||
const destination = seenOnboarding ? '/login' : '/onboarding/intro'
|
||||
router.push(destination).catch(() => {})
|
||||
} catch {
|
||||
router.push('/onboarding/intro').catch(() => {})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Global styles are in style.css */
|
||||
</style>
|
||||
178
neode-ui/src/api/__tests__/container-client.test.ts
Normal file
178
neode-ui/src/api/__tests__/container-client.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// Mock the rpc-client module
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { containerClient } from '../container-client'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
const mockedRpc = vi.mocked(rpcClient)
|
||||
|
||||
describe('containerClient', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('installApp calls container-install with manifest path', async () => {
|
||||
mockedRpc.call.mockResolvedValue('container-abc123')
|
||||
|
||||
const result = await containerClient.installApp('/apps/bitcoin/manifest.yml')
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'container-install',
|
||||
params: { manifest_path: '/apps/bitcoin/manifest.yml' },
|
||||
})
|
||||
expect(result).toBe('container-abc123')
|
||||
})
|
||||
|
||||
it('startContainer calls container-start with app_id', async () => {
|
||||
mockedRpc.call.mockResolvedValue(undefined)
|
||||
|
||||
await containerClient.startContainer('bitcoin-knots')
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'container-start',
|
||||
params: { app_id: 'bitcoin-knots' },
|
||||
})
|
||||
})
|
||||
|
||||
it('stopContainer calls container-stop with app_id', async () => {
|
||||
mockedRpc.call.mockResolvedValue(undefined)
|
||||
|
||||
await containerClient.stopContainer('lnd')
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'container-stop',
|
||||
params: { app_id: 'lnd' },
|
||||
})
|
||||
})
|
||||
|
||||
it('removeContainer calls container-remove with app_id', async () => {
|
||||
mockedRpc.call.mockResolvedValue(undefined)
|
||||
|
||||
await containerClient.removeContainer('mempool')
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'container-remove',
|
||||
params: { app_id: 'mempool' },
|
||||
})
|
||||
})
|
||||
|
||||
it('getContainerStatus returns status for a container', async () => {
|
||||
const mockStatus = {
|
||||
id: '1',
|
||||
name: 'bitcoin-knots',
|
||||
state: 'running' as const,
|
||||
image: 'bitcoinknots:29',
|
||||
created: '2026-01-01',
|
||||
ports: ['8332'],
|
||||
lan_address: 'http://localhost:8332',
|
||||
}
|
||||
mockedRpc.call.mockResolvedValue(mockStatus)
|
||||
|
||||
const result = await containerClient.getContainerStatus('bitcoin-knots')
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'container-status',
|
||||
params: { app_id: 'bitcoin-knots' },
|
||||
})
|
||||
expect(result).toEqual(mockStatus)
|
||||
})
|
||||
|
||||
it('getContainerLogs returns log lines with default line count', async () => {
|
||||
const mockLogs = ['Starting bitcoin...', 'Block 850000 synced', 'Peer connected']
|
||||
mockedRpc.call.mockResolvedValue(mockLogs)
|
||||
|
||||
const result = await containerClient.getContainerLogs('bitcoin-knots')
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'container-logs',
|
||||
params: { app_id: 'bitcoin-knots', lines: 100 },
|
||||
})
|
||||
expect(result).toEqual(mockLogs)
|
||||
})
|
||||
|
||||
it('getContainerLogs respects custom line count', async () => {
|
||||
mockedRpc.call.mockResolvedValue([])
|
||||
|
||||
await containerClient.getContainerLogs('lnd', 50)
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'container-logs',
|
||||
params: { app_id: 'lnd', lines: 50 },
|
||||
})
|
||||
})
|
||||
|
||||
it('listContainers returns all containers', async () => {
|
||||
const mockContainers = [
|
||||
{ id: '1', name: 'bitcoin-knots', state: 'running', image: 'bitcoinknots:29', created: '2026-01-01', ports: ['8332'] },
|
||||
{ id: '2', name: 'lnd', state: 'stopped', image: 'lnd:v0.18', created: '2026-01-01', ports: ['9735'] },
|
||||
]
|
||||
mockedRpc.call.mockResolvedValue(mockContainers)
|
||||
|
||||
const result = await containerClient.listContainers()
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'container-list',
|
||||
params: {},
|
||||
})
|
||||
expect(result).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('getHealthStatus returns health map', async () => {
|
||||
const mockHealth = { 'bitcoin-knots': 'healthy', lnd: 'unhealthy' }
|
||||
mockedRpc.call.mockResolvedValue(mockHealth)
|
||||
|
||||
const result = await containerClient.getHealthStatus()
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'container-health',
|
||||
params: {},
|
||||
})
|
||||
expect(result).toEqual(mockHealth)
|
||||
})
|
||||
|
||||
it('startBundledApp sends full app config', async () => {
|
||||
mockedRpc.call.mockResolvedValue(undefined)
|
||||
const app = {
|
||||
id: 'filebrowser',
|
||||
name: 'FileBrowser',
|
||||
image: 'filebrowser/filebrowser:v2',
|
||||
ports: [{ host: 8083, container: 80 }],
|
||||
volumes: [{ host: '/var/lib/archipelago/filebrowser', container: '/srv' }],
|
||||
}
|
||||
|
||||
await containerClient.startBundledApp(app)
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'bundled-app-start',
|
||||
params: {
|
||||
app_id: 'filebrowser',
|
||||
image: 'filebrowser/filebrowser:v2',
|
||||
ports: [{ host: 8083, container: 80 }],
|
||||
volumes: [{ host: '/var/lib/archipelago/filebrowser', container: '/srv' }],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('stopBundledApp calls bundled-app-stop', async () => {
|
||||
mockedRpc.call.mockResolvedValue(undefined)
|
||||
|
||||
await containerClient.stopBundledApp('filebrowser')
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'bundled-app-stop',
|
||||
params: { app_id: 'filebrowser' },
|
||||
})
|
||||
})
|
||||
|
||||
it('propagates RPC errors from the client', async () => {
|
||||
mockedRpc.call.mockRejectedValue(new Error('Connection refused'))
|
||||
|
||||
await expect(containerClient.startContainer('bitcoin-knots')).rejects.toThrow('Connection refused')
|
||||
})
|
||||
})
|
||||
330
neode-ui/src/api/__tests__/filebrowser-client.test.ts
Normal file
330
neode-ui/src/api/__tests__/filebrowser-client.test.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { sanitizePath } from '../filebrowser-client'
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
// FileBrowserClient reads window.location.origin in constructor, so stub it
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { origin: 'http://localhost', protocol: 'http:', hostname: 'localhost' },
|
||||
writable: true,
|
||||
})
|
||||
|
||||
// Import after stubs
|
||||
const { fileBrowserClient } = await import('../filebrowser-client')
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: status === 200 ? 'OK' : 'Error',
|
||||
json: () => Promise.resolve(body),
|
||||
text: () => Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)),
|
||||
blob: () => Promise.resolve(new Blob([JSON.stringify(body)])),
|
||||
headers: new Headers(),
|
||||
redirected: false,
|
||||
type: 'basic' as ResponseType,
|
||||
url: '',
|
||||
clone: () => jsonResponse(body, status),
|
||||
body: null,
|
||||
bodyUsed: false,
|
||||
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
|
||||
formData: () => Promise.resolve(new FormData()),
|
||||
bytes: () => Promise.resolve(new Uint8Array()),
|
||||
}
|
||||
}
|
||||
|
||||
describe('FileBrowserClient', () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset()
|
||||
})
|
||||
|
||||
describe('login', () => {
|
||||
it('authenticates and stores token', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"jwt-token-123"'))
|
||||
|
||||
// We need a fresh instance to test login — use the exported singleton
|
||||
const result = await fileBrowserClient.login('admin', 'admin')
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(fileBrowserClient.isAuthenticated).toBe(true)
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/app/filebrowser/api/login'),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username: 'admin', password: 'admin' }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('returns false on failed login', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 403))
|
||||
|
||||
const result = await fileBrowserClient.login('admin', 'wrong')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false on network error', async () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const result = await fileBrowserClient.login()
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('listDirectory', () => {
|
||||
it('lists items in a directory', async () => {
|
||||
// Ensure authenticated first
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
const mockItems = {
|
||||
items: [
|
||||
{ name: 'photos', path: '/photos', size: 0, modified: '2026-01-01', isDir: true, type: '', extension: '' },
|
||||
{ name: 'readme.txt', path: '/readme.txt', size: 1024, modified: '2026-01-01', isDir: false, type: '', extension: 'txt' },
|
||||
],
|
||||
numDirs: 1,
|
||||
numFiles: 1,
|
||||
sorting: { by: 'name', asc: true },
|
||||
}
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(mockItems))
|
||||
|
||||
const items = await fileBrowserClient.listDirectory('/')
|
||||
|
||||
expect(items).toHaveLength(2)
|
||||
expect(items[0]!.name).toBe('photos')
|
||||
expect(items[1]!.extension).toBe('txt')
|
||||
})
|
||||
|
||||
it('adds leading slash if missing', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ items: [], numDirs: 0, numFiles: 0, sorting: { by: 'name', asc: true } }))
|
||||
|
||||
await fileBrowserClient.listDirectory('photos')
|
||||
|
||||
const [url] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
|
||||
expect(url).toContain('/api/resources/photos')
|
||||
})
|
||||
|
||||
it('throws on non-OK response', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 404))
|
||||
|
||||
await expect(fileBrowserClient.listDirectory('/missing')).rejects.toThrow('Failed to list directory: 404')
|
||||
})
|
||||
})
|
||||
|
||||
describe('downloadUrl', () => {
|
||||
it('constructs download URL for file path', async () => {
|
||||
const url = fileBrowserClient.downloadUrl('/photos/sunset.jpg')
|
||||
|
||||
expect(url).toContain('/api/raw/photos/sunset.jpg')
|
||||
})
|
||||
|
||||
it('adds leading slash if missing', async () => {
|
||||
const url = fileBrowserClient.downloadUrl('file.txt')
|
||||
|
||||
expect(url).toContain('/api/raw/file.txt')
|
||||
})
|
||||
})
|
||||
|
||||
describe('upload', () => {
|
||||
it('uploads a file to the correct path', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
|
||||
const file = new File(['hello'], 'test.txt', { type: 'text/plain' })
|
||||
|
||||
await fileBrowserClient.upload('/documents', file)
|
||||
|
||||
const [url, init] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
|
||||
expect(url).toContain('/api/resources/documents/test.txt')
|
||||
expect(url).toContain('override=true')
|
||||
expect(init.method).toBe('POST')
|
||||
expect(init.body).toBe(file)
|
||||
})
|
||||
|
||||
it('throws on upload failure', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('Disk full', 507))
|
||||
const file = new File(['data'], 'big.bin')
|
||||
|
||||
await expect(fileBrowserClient.upload('/', file)).rejects.toThrow('Upload failed (507)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createFolder', () => {
|
||||
it('creates a folder at the correct path', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
|
||||
|
||||
await fileBrowserClient.createFolder('/documents', 'photos')
|
||||
|
||||
const [url, init] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
|
||||
expect(url).toContain('/api/resources/documents/photos/')
|
||||
expect(init.method).toBe('POST')
|
||||
})
|
||||
|
||||
it('throws on failure', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 500))
|
||||
|
||||
await expect(fileBrowserClient.createFolder('/', 'test')).rejects.toThrow('Create folder failed: 500')
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteItem', () => {
|
||||
it('sends DELETE request for the item', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
|
||||
|
||||
await fileBrowserClient.deleteItem('/photos/old.jpg')
|
||||
|
||||
const [url, init] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
|
||||
expect(url).toContain('/api/resources/photos/old.jpg')
|
||||
expect(init.method).toBe('DELETE')
|
||||
})
|
||||
|
||||
it('throws on failure', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 403))
|
||||
|
||||
await expect(fileBrowserClient.deleteItem('/protected')).rejects.toThrow('Delete failed: 403')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getUsage', () => {
|
||||
it('returns usage summary for root directory', async () => {
|
||||
// Login first
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
const mockData = {
|
||||
items: [
|
||||
{ name: 'photos', path: '/photos', size: 0, modified: '2026-01-01', isDir: true, type: '', extension: '' },
|
||||
{ name: 'file1.txt', path: '/file1.txt', size: 500, modified: '2026-01-01', isDir: false, type: '', extension: 'txt' },
|
||||
{ name: 'file2.jpg', path: '/file2.jpg', size: 1500, modified: '2026-01-01', isDir: false, type: '', extension: 'jpg' },
|
||||
],
|
||||
numDirs: 1,
|
||||
numFiles: 2,
|
||||
sorting: { by: 'name', asc: true },
|
||||
}
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(mockData))
|
||||
|
||||
const usage = await fileBrowserClient.getUsage()
|
||||
|
||||
expect(usage.totalSize).toBe(2000)
|
||||
expect(usage.folderCount).toBe(1)
|
||||
expect(usage.fileCount).toBe(2)
|
||||
})
|
||||
|
||||
it('returns zeros on failed request', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 500))
|
||||
|
||||
const usage = await fileBrowserClient.getUsage()
|
||||
|
||||
expect(usage).toEqual({ totalSize: 0, folderCount: 0, fileCount: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('isTextFile', () => {
|
||||
it('identifies text file extensions', () => {
|
||||
expect(fileBrowserClient.isTextFile('readme.md')).toBe(true)
|
||||
expect(fileBrowserClient.isTextFile('config.json')).toBe(true)
|
||||
expect(fileBrowserClient.isTextFile('script.py')).toBe(true)
|
||||
expect(fileBrowserClient.isTextFile('main.rs')).toBe(true)
|
||||
expect(fileBrowserClient.isTextFile('style.css')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for binary files', () => {
|
||||
expect(fileBrowserClient.isTextFile('photo.jpg')).toBe(false)
|
||||
expect(fileBrowserClient.isTextFile('video.mp4')).toBe(false)
|
||||
expect(fileBrowserClient.isTextFile('archive.zip')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rename', () => {
|
||||
it('sends PATCH request with new destination', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
|
||||
|
||||
await fileBrowserClient.rename('/photos/old.jpg', 'new.jpg')
|
||||
|
||||
const [url, init] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
|
||||
expect(url).toContain('/api/resources/photos/old.jpg')
|
||||
expect(init.method).toBe('PATCH')
|
||||
expect(JSON.parse(init.body)).toEqual({ destination: '/photos/new.jpg' })
|
||||
})
|
||||
|
||||
it('throws on rename failure', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 409))
|
||||
|
||||
await expect(fileBrowserClient.rename('/a.txt', 'b.txt')).rejects.toThrow('Rename failed: 409')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitizePath', () => {
|
||||
it('returns / for empty path', () => {
|
||||
expect(sanitizePath('')).toBe('/')
|
||||
})
|
||||
|
||||
it('preserves simple paths', () => {
|
||||
expect(sanitizePath('/photos')).toBe('/photos')
|
||||
expect(sanitizePath('/docs/readme.md')).toBe('/docs/readme.md')
|
||||
})
|
||||
|
||||
it('adds leading slash', () => {
|
||||
expect(sanitizePath('photos/image.jpg')).toBe('/photos/image.jpg')
|
||||
})
|
||||
|
||||
it('resolves . segments', () => {
|
||||
expect(sanitizePath('/photos/./image.jpg')).toBe('/photos/image.jpg')
|
||||
})
|
||||
|
||||
it('resolves .. segments', () => {
|
||||
expect(sanitizePath('/photos/../etc/passwd')).toBe('/etc/passwd')
|
||||
})
|
||||
|
||||
it('prevents traversal past root', () => {
|
||||
expect(sanitizePath('/../../../etc/passwd')).toBe('/etc/passwd')
|
||||
expect(sanitizePath('/../../..')).toBe('/')
|
||||
})
|
||||
|
||||
it('handles multiple consecutive .. at root', () => {
|
||||
expect(sanitizePath('/../../../etc/shadow')).toBe('/etc/shadow')
|
||||
})
|
||||
|
||||
it('handles mixed . and .. segments', () => {
|
||||
expect(sanitizePath('/a/./b/../c')).toBe('/a/c')
|
||||
})
|
||||
|
||||
it('removes trailing slashes in segments', () => {
|
||||
expect(sanitizePath('/photos//image.jpg')).toBe('/photos/image.jpg')
|
||||
})
|
||||
})
|
||||
567
neode-ui/src/api/__tests__/rpc-client.test.ts
Normal file
567
neode-ui/src/api/__tests__/rpc-client.test.ts
Normal file
@@ -0,0 +1,567 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
// We need to test the RPCClient class, so import it by re-creating the module
|
||||
// Import the actual class and instance
|
||||
const mockFetch = vi.fn()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
// Import after stubbing fetch
|
||||
const { rpcClient } = await import('../rpc-client')
|
||||
|
||||
function jsonResponse(body: unknown, status = 200, statusText = 'OK'): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText,
|
||||
json: () => Promise.resolve(body),
|
||||
headers: new Headers(),
|
||||
redirected: false,
|
||||
type: 'basic' as ResponseType,
|
||||
url: '',
|
||||
clone: () => jsonResponse(body, status, statusText),
|
||||
body: null,
|
||||
bodyUsed: false,
|
||||
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
|
||||
blob: () => Promise.resolve(new Blob()),
|
||||
formData: () => Promise.resolve(new FormData()),
|
||||
text: () => Promise.resolve(JSON.stringify(body)),
|
||||
bytes: () => Promise.resolve(new Uint8Array()),
|
||||
}
|
||||
}
|
||||
|
||||
describe('RPCClient', () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset()
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('makes a successful RPC call and returns the result', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ result: { did: 'did:key:z123' } }))
|
||||
|
||||
const result = await rpcClient.call<{ did: string }>({
|
||||
method: 'node.did',
|
||||
params: {},
|
||||
})
|
||||
|
||||
expect(result).toEqual({ did: 'did:key:z123' })
|
||||
expect(mockFetch).toHaveBeenCalledOnce()
|
||||
const [url, init] = mockFetch.mock.calls[0]!
|
||||
expect(url).toBe('/rpc/v1')
|
||||
expect(init.method).toBe('POST')
|
||||
expect(init.credentials).toBe('include')
|
||||
expect(JSON.parse(init.body)).toEqual({ method: 'node.did', params: {} })
|
||||
})
|
||||
|
||||
it('includes credentials for session cookies', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ result: 'ok' }))
|
||||
|
||||
await rpcClient.call({ method: 'test', params: {} })
|
||||
|
||||
const [, init] = mockFetch.mock.calls[0]!
|
||||
expect(init.credentials).toBe('include')
|
||||
})
|
||||
|
||||
it('retries on 502 Bad Gateway and eventually succeeds', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(jsonResponse(null, 502, 'Bad Gateway'))
|
||||
.mockResolvedValueOnce(jsonResponse({ result: 'ok' }))
|
||||
|
||||
const result = await rpcClient.call({ method: 'test' })
|
||||
|
||||
expect(result).toBe('ok')
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('retries on 503 Service Unavailable and eventually succeeds', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(jsonResponse(null, 503, 'Service Unavailable'))
|
||||
.mockResolvedValueOnce(jsonResponse({ result: 'recovered' }))
|
||||
|
||||
const result = await rpcClient.call({ method: 'test' })
|
||||
|
||||
expect(result).toBe('recovered')
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('throws after max retries on persistent 502', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValue(jsonResponse(null, 502, 'Bad Gateway'))
|
||||
|
||||
await expect(rpcClient.call({ method: 'test' })).rejects.toThrow('HTTP 502: Bad Gateway')
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('throws immediately on non-retryable HTTP errors (e.g. 401)', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 401, 'Unauthorized'))
|
||||
|
||||
await expect(rpcClient.call({ method: 'test' })).rejects.toThrow('Session expired')
|
||||
expect(mockFetch).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('throws on RPC-level error in response body', async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
jsonResponse({ error: { code: -32600, message: 'Invalid method' } }),
|
||||
)
|
||||
|
||||
await expect(rpcClient.call({ method: 'bad.method' })).rejects.toThrow('Invalid method')
|
||||
expect(mockFetch).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('throws timeout error when request times out', async () => {
|
||||
const abortError = Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' })
|
||||
mockFetch.mockRejectedValue(abortError)
|
||||
|
||||
await expect(
|
||||
rpcClient.call({ method: 'slow', timeout: 100 }),
|
||||
).rejects.toThrow('Request timeout')
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('retries on network/fetch errors and eventually succeeds', async () => {
|
||||
mockFetch
|
||||
.mockRejectedValueOnce(new Error('fetch failed'))
|
||||
.mockResolvedValueOnce(jsonResponse({ result: 'back online' }))
|
||||
|
||||
const result = await rpcClient.call({ method: 'test' })
|
||||
|
||||
expect(result).toBe('back online')
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('throws on non-retryable errors immediately', async () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error('some random error'))
|
||||
|
||||
await expect(rpcClient.call({ method: 'test' })).rejects.toThrow('some random error')
|
||||
expect(mockFetch).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('handles unknown (non-Error) thrown values', async () => {
|
||||
mockFetch.mockRejectedValueOnce('string error')
|
||||
|
||||
await expect(rpcClient.call({ method: 'test' })).rejects.toThrow('Unknown error occurred')
|
||||
})
|
||||
|
||||
it('uses default params when none provided', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ result: 'ok' }))
|
||||
|
||||
await rpcClient.call({ method: 'test' })
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0]![1].body)
|
||||
expect(body.params).toEqual({})
|
||||
})
|
||||
|
||||
it('sends an abort signal for timeout', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ result: 'ok' }))
|
||||
|
||||
await rpcClient.call({ method: 'test', timeout: 5000 })
|
||||
|
||||
const [, init] = mockFetch.mock.calls[0]!
|
||||
expect(init.signal).toBeInstanceOf(AbortSignal)
|
||||
})
|
||||
})
|
||||
|
||||
describe('RPCClient convenience methods', () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset()
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function mockSuccess(result: unknown) {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ result }))
|
||||
}
|
||||
|
||||
function getLastMethod(): string {
|
||||
const body = JSON.parse(mockFetch.mock.calls[0]![1].body)
|
||||
return body.method
|
||||
}
|
||||
|
||||
function getLastParams(): Record<string, unknown> {
|
||||
const body = JSON.parse(mockFetch.mock.calls[0]![1].body)
|
||||
return body.params
|
||||
}
|
||||
|
||||
it('login calls auth.login with password', async () => {
|
||||
mockSuccess(null)
|
||||
await rpcClient.login('test123')
|
||||
expect(getLastMethod()).toBe('auth.login')
|
||||
expect(getLastParams().password).toBe('test123')
|
||||
})
|
||||
|
||||
it('loginTotp calls auth.login.totp', async () => {
|
||||
mockSuccess({ success: true })
|
||||
await rpcClient.loginTotp('123456')
|
||||
expect(getLastMethod()).toBe('auth.login.totp')
|
||||
expect(getLastParams().code).toBe('123456')
|
||||
})
|
||||
|
||||
it('loginBackup calls auth.login.backup', async () => {
|
||||
mockSuccess({ success: true })
|
||||
await rpcClient.loginBackup('ABCD-1234')
|
||||
expect(getLastMethod()).toBe('auth.login.backup')
|
||||
expect(getLastParams().code).toBe('ABCD-1234')
|
||||
})
|
||||
|
||||
it('totpSetupBegin calls auth.totp.setup.begin', async () => {
|
||||
mockSuccess({ qr_svg: '<svg/>', secret_base32: 'ABC', pending_token: 'tok' })
|
||||
await rpcClient.totpSetupBegin('password')
|
||||
expect(getLastMethod()).toBe('auth.totp.setup.begin')
|
||||
})
|
||||
|
||||
it('totpSetupConfirm calls auth.totp.setup.confirm', async () => {
|
||||
mockSuccess({ enabled: true, backup_codes: ['A', 'B'] })
|
||||
await rpcClient.totpSetupConfirm({ code: '123456', password: 'pw', pendingToken: 'tok' })
|
||||
expect(getLastMethod()).toBe('auth.totp.setup.confirm')
|
||||
})
|
||||
|
||||
it('totpDisable calls auth.totp.disable', async () => {
|
||||
mockSuccess({ disabled: true })
|
||||
await rpcClient.totpDisable('pw', '123456')
|
||||
expect(getLastMethod()).toBe('auth.totp.disable')
|
||||
})
|
||||
|
||||
it('totpStatus calls auth.totp.status', async () => {
|
||||
mockSuccess({ enabled: false })
|
||||
await rpcClient.totpStatus()
|
||||
expect(getLastMethod()).toBe('auth.totp.status')
|
||||
})
|
||||
|
||||
it('changePassword calls auth.changePassword', async () => {
|
||||
mockSuccess({ success: true })
|
||||
await rpcClient.changePassword({ currentPassword: 'old', newPassword: 'new' })
|
||||
expect(getLastMethod()).toBe('auth.changePassword')
|
||||
expect(getLastParams().alsoChangeSsh).toBe(true)
|
||||
})
|
||||
|
||||
it('changePassword respects alsoChangeSsh option', async () => {
|
||||
mockSuccess({ success: true })
|
||||
await rpcClient.changePassword({ currentPassword: 'old', newPassword: 'new', alsoChangeSsh: false })
|
||||
expect(getLastParams().alsoChangeSsh).toBe(false)
|
||||
})
|
||||
|
||||
it('logout calls auth.logout', async () => {
|
||||
mockSuccess(undefined)
|
||||
await rpcClient.logout()
|
||||
expect(getLastMethod()).toBe('auth.logout')
|
||||
})
|
||||
|
||||
it('completeOnboarding calls auth.onboardingComplete', async () => {
|
||||
mockSuccess(true)
|
||||
await rpcClient.completeOnboarding()
|
||||
expect(getLastMethod()).toBe('auth.onboardingComplete')
|
||||
})
|
||||
|
||||
it('isOnboardingComplete calls auth.isOnboardingComplete', async () => {
|
||||
mockSuccess(true)
|
||||
const result = await rpcClient.isOnboardingComplete()
|
||||
expect(result).toBe(true)
|
||||
expect(getLastMethod()).toBe('auth.isOnboardingComplete')
|
||||
})
|
||||
|
||||
it('resetOnboarding calls auth.resetOnboarding', async () => {
|
||||
mockSuccess(true)
|
||||
await rpcClient.resetOnboarding()
|
||||
expect(getLastMethod()).toBe('auth.resetOnboarding')
|
||||
})
|
||||
|
||||
it('getNodeDid calls node.did', async () => {
|
||||
mockSuccess({ did: 'did:key:z123', pubkey: 'abc' })
|
||||
const result = await rpcClient.getNodeDid()
|
||||
expect(result.did).toBe('did:key:z123')
|
||||
expect(getLastMethod()).toBe('node.did')
|
||||
})
|
||||
|
||||
it('signChallenge calls node.signChallenge', async () => {
|
||||
mockSuccess({ signature: 'sig123' })
|
||||
await rpcClient.signChallenge('test-challenge')
|
||||
expect(getLastMethod()).toBe('node.signChallenge')
|
||||
expect(getLastParams().challenge).toBe('test-challenge')
|
||||
})
|
||||
|
||||
it('createBackup calls node.createBackup', async () => {
|
||||
mockSuccess({ version: 1, did: 'did:key:z', pubkey: 'pk', kid: 'k1', encrypted: true, blob: 'data', timestamp: '2026-01-01' })
|
||||
await rpcClient.createBackup('passphrase')
|
||||
expect(getLastMethod()).toBe('node.createBackup')
|
||||
})
|
||||
|
||||
it('resolveDid calls identity.resolve-did', async () => {
|
||||
mockSuccess({})
|
||||
await rpcClient.resolveDid('did:key:z123')
|
||||
expect(getLastMethod()).toBe('identity.resolve-did')
|
||||
expect(getLastParams().did).toBe('did:key:z123')
|
||||
})
|
||||
|
||||
it('resolveDid without did sends empty params', async () => {
|
||||
mockSuccess({})
|
||||
await rpcClient.resolveDid()
|
||||
expect(getLastParams()).toEqual({})
|
||||
})
|
||||
|
||||
it('createPresentation calls identity.create-presentation', async () => {
|
||||
mockSuccess({})
|
||||
await rpcClient.createPresentation({ holderId: 'h1', credentialIds: ['c1'] })
|
||||
expect(getLastMethod()).toBe('identity.create-presentation')
|
||||
})
|
||||
|
||||
it('verifyPresentation calls identity.verify-presentation', async () => {
|
||||
mockSuccess({ valid: true, holder_valid: true, credentials: [] })
|
||||
await rpcClient.verifyPresentation({ type: 'test' })
|
||||
expect(getLastMethod()).toBe('identity.verify-presentation')
|
||||
})
|
||||
|
||||
it('createPsbt calls lnd.create-psbt', async () => {
|
||||
mockSuccess({ psbt_base64: 'psbt', change_output_index: 0, total_amount_sats: 1000, fee_rate_sat_per_vbyte: 10 })
|
||||
await rpcClient.createPsbt({ outputs: [{ address: 'bc1q...', amount_sats: 1000 }] })
|
||||
expect(getLastMethod()).toBe('lnd.create-psbt')
|
||||
expect(getLastParams().fee_rate_sat_per_vbyte).toBe(10)
|
||||
})
|
||||
|
||||
it('finalizePsbt calls lnd.finalize-psbt', async () => {
|
||||
mockSuccess({ raw_final_tx: 'rawtx', broadcast: true })
|
||||
await rpcClient.finalizePsbt('signed-psbt')
|
||||
expect(getLastMethod()).toBe('lnd.finalize-psbt')
|
||||
})
|
||||
|
||||
it('publishNostrIdentity calls node.nostr-publish', async () => {
|
||||
mockSuccess({ event_id: 'evt', success: 1, failed: 0 })
|
||||
await rpcClient.publishNostrIdentity()
|
||||
expect(getLastMethod()).toBe('node.nostr-publish')
|
||||
})
|
||||
|
||||
it('getNostrPubkey calls node.nostr-pubkey', async () => {
|
||||
mockSuccess({ nostr_pubkey: 'npub1...' })
|
||||
await rpcClient.getNostrPubkey()
|
||||
expect(getLastMethod()).toBe('node.nostr-pubkey')
|
||||
})
|
||||
|
||||
it('listPeers calls node-list-peers', async () => {
|
||||
mockSuccess({ peers: [] })
|
||||
await rpcClient.listPeers()
|
||||
expect(getLastMethod()).toBe('node-list-peers')
|
||||
})
|
||||
|
||||
it('addPeer calls node-add-peer', async () => {
|
||||
mockSuccess({ peers: [] })
|
||||
await rpcClient.addPeer({ onion: 'abc.onion', pubkey: 'pk' })
|
||||
expect(getLastMethod()).toBe('node-add-peer')
|
||||
})
|
||||
|
||||
it('removePeer calls node-remove-peer', async () => {
|
||||
mockSuccess({ peers: [] })
|
||||
await rpcClient.removePeer('pk123')
|
||||
expect(getLastMethod()).toBe('node-remove-peer')
|
||||
})
|
||||
|
||||
it('sendMessageToPeer calls node-send-message', async () => {
|
||||
mockSuccess({ ok: true, sent_to: 'abc.onion' })
|
||||
await rpcClient.sendMessageToPeer('abc.onion', 'hello')
|
||||
expect(getLastMethod()).toBe('node-send-message')
|
||||
})
|
||||
|
||||
it('checkPeerReachable calls node-check-peer', async () => {
|
||||
mockSuccess({ onion: 'abc.onion', reachable: true })
|
||||
await rpcClient.checkPeerReachable('abc.onion')
|
||||
expect(getLastMethod()).toBe('node-check-peer')
|
||||
})
|
||||
|
||||
it('getReceivedMessages calls node-messages-received', async () => {
|
||||
mockSuccess({ messages: [] })
|
||||
await rpcClient.getReceivedMessages()
|
||||
expect(getLastMethod()).toBe('node-messages-received')
|
||||
})
|
||||
|
||||
it('discoverNodes calls node-nostr-discover', async () => {
|
||||
mockSuccess({ nodes: [] })
|
||||
await rpcClient.discoverNodes()
|
||||
expect(getLastMethod()).toBe('node-nostr-discover')
|
||||
})
|
||||
|
||||
it('getTorAddress calls node.tor-address', async () => {
|
||||
mockSuccess({ tor_address: 'abc123.onion' })
|
||||
await rpcClient.getTorAddress()
|
||||
expect(getLastMethod()).toBe('node.tor-address')
|
||||
})
|
||||
|
||||
it('verifyNostrRevoked calls node-nostr-verify-revoked', async () => {
|
||||
mockSuccess({ revoked: false, nostr_pubkey: 'npub' })
|
||||
await rpcClient.verifyNostrRevoked()
|
||||
expect(getLastMethod()).toBe('node-nostr-verify-revoked')
|
||||
})
|
||||
|
||||
it('echo calls server.echo', async () => {
|
||||
mockSuccess('hello')
|
||||
const result = await rpcClient.echo('hello')
|
||||
expect(result).toBe('hello')
|
||||
expect(getLastMethod()).toBe('server.echo')
|
||||
})
|
||||
|
||||
it('getSystemTime calls server.time', async () => {
|
||||
mockSuccess({ now: '2026-03-11', uptime: 3600 })
|
||||
await rpcClient.getSystemTime()
|
||||
expect(getLastMethod()).toBe('server.time')
|
||||
})
|
||||
|
||||
it('getMetrics calls server.metrics', async () => {
|
||||
mockSuccess({ cpu: 50 })
|
||||
await rpcClient.getMetrics()
|
||||
expect(getLastMethod()).toBe('server.metrics')
|
||||
})
|
||||
|
||||
it('updateServer calls server.update', async () => {
|
||||
mockSuccess('no-updates')
|
||||
await rpcClient.updateServer('https://example.com')
|
||||
expect(getLastMethod()).toBe('server.update')
|
||||
})
|
||||
|
||||
it('detectUsbDevices calls system.detect-usb-devices', async () => {
|
||||
mockSuccess({ devices: [] })
|
||||
await rpcClient.detectUsbDevices()
|
||||
expect(getLastMethod()).toBe('system.detect-usb-devices')
|
||||
})
|
||||
|
||||
it('restartServer calls server.restart', async () => {
|
||||
mockSuccess(undefined)
|
||||
await rpcClient.restartServer()
|
||||
expect(getLastMethod()).toBe('server.restart')
|
||||
})
|
||||
|
||||
it('shutdownServer calls server.shutdown', async () => {
|
||||
mockSuccess(undefined)
|
||||
await rpcClient.shutdownServer()
|
||||
expect(getLastMethod()).toBe('server.shutdown')
|
||||
})
|
||||
|
||||
it('installPackage calls package.install', async () => {
|
||||
mockSuccess('bitcoin-knots')
|
||||
await rpcClient.installPackage('btc', 'https://mp.com', '1.0')
|
||||
expect(getLastMethod()).toBe('package.install')
|
||||
})
|
||||
|
||||
it('uninstallPackage calls package.uninstall', async () => {
|
||||
mockSuccess(undefined)
|
||||
await rpcClient.uninstallPackage('btc')
|
||||
expect(getLastMethod()).toBe('package.uninstall')
|
||||
})
|
||||
|
||||
it('startPackage calls package.start', async () => {
|
||||
mockSuccess(undefined)
|
||||
await rpcClient.startPackage('btc')
|
||||
expect(getLastMethod()).toBe('package.start')
|
||||
})
|
||||
|
||||
it('stopPackage calls package.stop', async () => {
|
||||
mockSuccess(undefined)
|
||||
await rpcClient.stopPackage('btc')
|
||||
expect(getLastMethod()).toBe('package.stop')
|
||||
})
|
||||
|
||||
it('restartPackage calls package.restart', async () => {
|
||||
mockSuccess(undefined)
|
||||
await rpcClient.restartPackage('btc')
|
||||
expect(getLastMethod()).toBe('package.restart')
|
||||
})
|
||||
|
||||
it('getMarketplace calls marketplace.get', async () => {
|
||||
mockSuccess({})
|
||||
await rpcClient.getMarketplace('https://mp.com')
|
||||
expect(getLastMethod()).toBe('marketplace.get')
|
||||
})
|
||||
|
||||
it('federationInvite calls federation.invite', async () => {
|
||||
mockSuccess({ code: 'ABC', did: 'did:key:z', onion: 'abc.onion' })
|
||||
await rpcClient.federationInvite()
|
||||
expect(getLastMethod()).toBe('federation.invite')
|
||||
})
|
||||
|
||||
it('federationJoin calls federation.join', async () => {
|
||||
mockSuccess({ joined: true, node: {} })
|
||||
await rpcClient.federationJoin('invite-code')
|
||||
expect(getLastMethod()).toBe('federation.join')
|
||||
})
|
||||
|
||||
it('federationListNodes calls federation.list-nodes', async () => {
|
||||
mockSuccess({ nodes: [] })
|
||||
await rpcClient.federationListNodes()
|
||||
expect(getLastMethod()).toBe('federation.list-nodes')
|
||||
})
|
||||
|
||||
it('federationRemoveNode calls federation.remove-node', async () => {
|
||||
mockSuccess({ removed: true, nodes_remaining: 0 })
|
||||
await rpcClient.federationRemoveNode('did:key:z')
|
||||
expect(getLastMethod()).toBe('federation.remove-node')
|
||||
})
|
||||
|
||||
it('federationSetTrust calls federation.set-trust', async () => {
|
||||
mockSuccess({ updated: true, did: 'did:key:z', trust_level: 'trusted' })
|
||||
await rpcClient.federationSetTrust('did:key:z', 'trusted')
|
||||
expect(getLastMethod()).toBe('federation.set-trust')
|
||||
})
|
||||
|
||||
it('federationSyncState calls federation.sync-state', async () => {
|
||||
mockSuccess({ synced: 1, failed: 0, results: [] })
|
||||
await rpcClient.federationSyncState()
|
||||
expect(getLastMethod()).toBe('federation.sync-state')
|
||||
})
|
||||
|
||||
it('federationDeployApp calls federation.deploy-app', async () => {
|
||||
mockSuccess({ deployed: true, app_id: 'btc', peer_did: 'did', peer_onion: 'onion' })
|
||||
await rpcClient.federationDeployApp({ did: 'did:key:z', appId: 'btc' })
|
||||
expect(getLastMethod()).toBe('federation.deploy-app')
|
||||
expect(getLastParams().version).toBe('latest')
|
||||
})
|
||||
|
||||
it('vpnStatus calls vpn.status', async () => {
|
||||
mockSuccess({ connected: false, peers_connected: 0, bytes_in: 0, bytes_out: 0, configured: false, configured_provider: '' })
|
||||
await rpcClient.vpnStatus()
|
||||
expect(getLastMethod()).toBe('vpn.status')
|
||||
})
|
||||
|
||||
it('vpnConfigure calls vpn.configure', async () => {
|
||||
mockSuccess({ configured: true, provider: 'tailscale' })
|
||||
await rpcClient.vpnConfigure({ provider: 'tailscale', auth_key: 'key' })
|
||||
expect(getLastMethod()).toBe('vpn.configure')
|
||||
})
|
||||
|
||||
it('vpnDisconnect calls vpn.disconnect', async () => {
|
||||
mockSuccess({ disconnected: true })
|
||||
await rpcClient.vpnDisconnect()
|
||||
expect(getLastMethod()).toBe('vpn.disconnect')
|
||||
})
|
||||
|
||||
it('marketplaceDiscover calls marketplace.discover', async () => {
|
||||
mockSuccess({ apps: [], relay_count: 0 })
|
||||
await rpcClient.marketplaceDiscover()
|
||||
expect(getLastMethod()).toBe('marketplace.discover')
|
||||
})
|
||||
|
||||
it('dnsStatus calls network.dns-status', async () => {
|
||||
mockSuccess({ provider: 'system', servers: [], doh_enabled: false, doh_url: null, resolv_conf_servers: [] })
|
||||
await rpcClient.dnsStatus()
|
||||
expect(getLastMethod()).toBe('network.dns-status')
|
||||
})
|
||||
|
||||
it('configureDns calls network.configure-dns', async () => {
|
||||
mockSuccess({ ok: true, provider: 'cloudflare', servers: [], doh_enabled: true, doh_url: null })
|
||||
await rpcClient.configureDns({ provider: 'cloudflare' })
|
||||
expect(getLastMethod()).toBe('network.configure-dns')
|
||||
})
|
||||
|
||||
it('diskStatus calls system.disk-status', async () => {
|
||||
mockSuccess({ used_bytes: 100, total_bytes: 1000, free_bytes: 900, used_percent: 10, level: 'ok' })
|
||||
await rpcClient.diskStatus()
|
||||
expect(getLastMethod()).toBe('system.disk-status')
|
||||
})
|
||||
|
||||
it('diskCleanup calls system.disk-cleanup', async () => {
|
||||
mockSuccess({ freed_bytes: 500, freed_human: '500B', actions: [] })
|
||||
await rpcClient.diskCleanup()
|
||||
expect(getLastMethod()).toBe('system.disk-cleanup')
|
||||
})
|
||||
})
|
||||
211
neode-ui/src/api/__tests__/rpc-marketplace.test.ts
Normal file
211
neode-ui/src/api/__tests__/rpc-marketplace.test.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
// Import after stubbing fetch
|
||||
const { rpcClient } = await import('../rpc-client')
|
||||
|
||||
function jsonResponse(body: unknown, status = 200, statusText = 'OK'): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText,
|
||||
json: () => Promise.resolve(body),
|
||||
headers: new Headers(),
|
||||
redirected: false,
|
||||
type: 'basic' as ResponseType,
|
||||
url: '',
|
||||
clone: () => jsonResponse(body, status, statusText),
|
||||
body: null,
|
||||
bodyUsed: false,
|
||||
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
|
||||
blob: () => Promise.resolve(new Blob()),
|
||||
formData: () => Promise.resolve(new FormData()),
|
||||
text: () => Promise.resolve(JSON.stringify(body)),
|
||||
bytes: () => Promise.resolve(new Uint8Array()),
|
||||
}
|
||||
}
|
||||
|
||||
describe('marketplaceDiscover', () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset()
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('returns apps array and relay_count on success', async () => {
|
||||
const payload = {
|
||||
apps: [
|
||||
{
|
||||
manifest: {
|
||||
app_id: 'bitcoin',
|
||||
name: 'Bitcoin Core',
|
||||
version: '27.0',
|
||||
description: { short: 'Full node', long: 'Bitcoin Core full node' },
|
||||
author: { name: 'Bitcoin', did: 'did:key:z111', nostr_pubkey: 'npub1abc' },
|
||||
container: { image: 'bitcoin:27.0', ports: [{ container: 8333, host: 8333 }] },
|
||||
category: 'bitcoin',
|
||||
icon_url: '/icons/bitcoin.png',
|
||||
repo_url: 'https://github.com/bitcoin/bitcoin',
|
||||
license: 'MIT',
|
||||
},
|
||||
trust_score: 95,
|
||||
trust_tier: 'verified',
|
||||
relay_count: 8,
|
||||
first_seen: '2025-01-15T00:00:00Z',
|
||||
nostr_pubkey: 'npub1abc',
|
||||
},
|
||||
],
|
||||
relay_count: 12,
|
||||
}
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
|
||||
|
||||
const result = await rpcClient.marketplaceDiscover()
|
||||
|
||||
expect(result.apps).toHaveLength(1)
|
||||
expect(result.apps[0]!.manifest.app_id).toBe('bitcoin')
|
||||
expect(result.apps[0]!.manifest.name).toBe('Bitcoin Core')
|
||||
expect(result.apps[0]!.trust_score).toBe(95)
|
||||
expect(result.relay_count).toBe(12)
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0]![1].body)
|
||||
expect(body.method).toBe('marketplace.discover')
|
||||
expect(body.params).toEqual({})
|
||||
})
|
||||
|
||||
it('handles empty results', async () => {
|
||||
const payload = {
|
||||
apps: [],
|
||||
relay_count: 0,
|
||||
}
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
|
||||
|
||||
const result = await rpcClient.marketplaceDiscover()
|
||||
|
||||
expect(result.apps).toEqual([])
|
||||
expect(result.apps).toHaveLength(0)
|
||||
expect(result.relay_count).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('diskStatus', () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset()
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('returns expected fields', async () => {
|
||||
const payload = {
|
||||
used_bytes: 500_000_000_000,
|
||||
total_bytes: 1_000_000_000_000,
|
||||
free_bytes: 500_000_000_000,
|
||||
used_percent: 50,
|
||||
level: 'ok' as const,
|
||||
}
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
|
||||
|
||||
const result = await rpcClient.diskStatus()
|
||||
|
||||
expect(result.used_bytes).toBe(500_000_000_000)
|
||||
expect(result.total_bytes).toBe(1_000_000_000_000)
|
||||
expect(result.free_bytes).toBe(500_000_000_000)
|
||||
expect(result.used_percent).toBe(50)
|
||||
expect(result.level).toBe('ok')
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0]![1].body)
|
||||
expect(body.method).toBe('system.disk-status')
|
||||
})
|
||||
|
||||
it('level is warning when percent >= 85', async () => {
|
||||
const payload = {
|
||||
used_bytes: 850_000_000_000,
|
||||
total_bytes: 1_000_000_000_000,
|
||||
free_bytes: 150_000_000_000,
|
||||
used_percent: 85,
|
||||
level: 'warning' as const,
|
||||
}
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
|
||||
|
||||
const result = await rpcClient.diskStatus()
|
||||
|
||||
expect(result.level).toBe('warning')
|
||||
expect(result.used_percent).toBe(85)
|
||||
})
|
||||
|
||||
it('level is critical when percent >= 90', async () => {
|
||||
const payload = {
|
||||
used_bytes: 950_000_000_000,
|
||||
total_bytes: 1_000_000_000_000,
|
||||
free_bytes: 50_000_000_000,
|
||||
used_percent: 95,
|
||||
level: 'critical' as const,
|
||||
}
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
|
||||
|
||||
const result = await rpcClient.diskStatus()
|
||||
|
||||
expect(result.level).toBe('critical')
|
||||
expect(result.used_percent).toBe(95)
|
||||
})
|
||||
})
|
||||
|
||||
describe('diskCleanup', () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset()
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('returns freed_bytes and actions array', async () => {
|
||||
const payload = {
|
||||
freed_bytes: 2_000_000_000,
|
||||
freed_human: '2 GB',
|
||||
actions: ['Removed 5 dangling images', 'Cleared build cache'],
|
||||
}
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
|
||||
|
||||
const result = await rpcClient.diskCleanup()
|
||||
|
||||
expect(result.freed_bytes).toBe(2_000_000_000)
|
||||
expect(result.freed_human).toBe('2 GB')
|
||||
expect(result.actions).toHaveLength(2)
|
||||
expect(result.actions[0]).toBe('Removed 5 dangling images')
|
||||
expect(result.actions[1]).toBe('Cleared build cache')
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0]![1].body)
|
||||
expect(body.method).toBe('system.disk-cleanup')
|
||||
})
|
||||
|
||||
it('uses 60s timeout', async () => {
|
||||
const abortError = Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' })
|
||||
mockFetch.mockRejectedValue(abortError)
|
||||
|
||||
const promise = rpcClient.diskCleanup()
|
||||
|
||||
// The call should eventually reject with timeout after retries
|
||||
await expect(promise).rejects.toThrow('Request timeout')
|
||||
|
||||
// Verify all 3 attempts used the signal (timeout is set via AbortController)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3)
|
||||
for (const call of mockFetch.mock.calls) {
|
||||
expect(call[1].signal).toBeInstanceOf(AbortSignal)
|
||||
}
|
||||
})
|
||||
})
|
||||
261
neode-ui/src/api/__tests__/websocket.test.ts
Normal file
261
neode-ui/src/api/__tests__/websocket.test.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
// Mock fast-json-patch
|
||||
vi.mock('fast-json-patch', () => ({
|
||||
applyPatch: vi.fn((doc: unknown, _ops: unknown[]) => ({
|
||||
newDocument: { ...doc as Record<string, unknown>, patched: true },
|
||||
})),
|
||||
}))
|
||||
|
||||
// Mock WebSocket
|
||||
class MockWebSocket {
|
||||
static CONNECTING = 0
|
||||
static OPEN = 1
|
||||
static CLOSING = 2
|
||||
static CLOSED = 3
|
||||
|
||||
readyState = MockWebSocket.CONNECTING
|
||||
onopen: ((ev: Event) => void) | null = null
|
||||
onclose: ((ev: CloseEvent) => void) | null = null
|
||||
onerror: ((ev: Event) => void) | null = null
|
||||
onmessage: ((ev: MessageEvent) => void) | null = null
|
||||
url: string
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url
|
||||
// Auto-open in next tick
|
||||
setTimeout(() => {
|
||||
this.readyState = MockWebSocket.OPEN
|
||||
this.onopen?.(new Event('open'))
|
||||
}, 0)
|
||||
}
|
||||
|
||||
send = vi.fn()
|
||||
close = vi.fn().mockImplementation(function (this: MockWebSocket) {
|
||||
this.readyState = MockWebSocket.CLOSED
|
||||
this.onclose?.(new CloseEvent('close', { code: 1000, wasClean: true }))
|
||||
})
|
||||
}
|
||||
|
||||
vi.stubGlobal('WebSocket', MockWebSocket)
|
||||
|
||||
// Must import after mocks
|
||||
const { WebSocketClient, applyDataPatch } = await import('../websocket')
|
||||
|
||||
describe('WebSocketClient', () => {
|
||||
let client: InstanceType<typeof WebSocketClient>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
client = new WebSocketClient('/ws/test')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
client.reset()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('initializes with disconnected state', () => {
|
||||
expect(client.state).toBe('disconnected')
|
||||
expect(client.isConnected()).toBe(false)
|
||||
})
|
||||
|
||||
it('connects and transitions to connected state', async () => {
|
||||
const states: string[] = []
|
||||
client.onConnectionStateChange((s) => states.push(s))
|
||||
|
||||
const connectPromise = client.connect()
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
await connectPromise
|
||||
|
||||
expect(client.state).toBe('connected')
|
||||
expect(client.isConnected()).toBe(true)
|
||||
expect(states).toContain('connecting')
|
||||
expect(states).toContain('connected')
|
||||
})
|
||||
|
||||
it('resolves immediately if already connected', async () => {
|
||||
const connectPromise = client.connect()
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
await connectPromise
|
||||
|
||||
// Second connect should resolve immediately
|
||||
await client.connect()
|
||||
expect(client.isConnected()).toBe(true)
|
||||
})
|
||||
|
||||
it('subscribe returns unsubscribe function', async () => {
|
||||
const callback = vi.fn()
|
||||
const unsub = client.subscribe(callback)
|
||||
|
||||
expect(typeof unsub).toBe('function')
|
||||
unsub()
|
||||
// Should not throw
|
||||
})
|
||||
|
||||
it('notifies subscribers on message', async () => {
|
||||
const callback = vi.fn()
|
||||
client.subscribe(callback)
|
||||
|
||||
const connectPromise = client.connect()
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
await connectPromise
|
||||
|
||||
// Simulate receiving a message
|
||||
const ws = (client as unknown as { ws: MockWebSocket }).ws
|
||||
const update = { id: 1, type: 'state', data: { running: true } }
|
||||
ws.onmessage?.(new MessageEvent('message', { data: JSON.stringify(update) }))
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(update)
|
||||
})
|
||||
|
||||
it('handles malformed JSON messages gracefully', async () => {
|
||||
const callback = vi.fn()
|
||||
client.subscribe(callback)
|
||||
|
||||
const connectPromise = client.connect()
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
await connectPromise
|
||||
|
||||
const ws = (client as unknown as { ws: MockWebSocket }).ws
|
||||
// Should not throw
|
||||
ws.onmessage?.(new MessageEvent('message', { data: 'not-json{' }))
|
||||
expect(callback).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('onConnectionStateChange returns unsubscribe function', () => {
|
||||
const callback = vi.fn()
|
||||
const unsub = client.onConnectionStateChange(callback)
|
||||
|
||||
expect(typeof unsub).toBe('function')
|
||||
unsub()
|
||||
})
|
||||
|
||||
it('disconnect sets state to disconnecting then cleans up', async () => {
|
||||
const states: string[] = []
|
||||
client.onConnectionStateChange((s) => states.push(s))
|
||||
|
||||
const connectPromise = client.connect()
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
await connectPromise
|
||||
|
||||
client.disconnect()
|
||||
|
||||
expect(states).toContain('disconnecting')
|
||||
expect(client.isConnected()).toBe(false)
|
||||
})
|
||||
|
||||
it('reset clears all callbacks and disconnects', async () => {
|
||||
const callback = vi.fn()
|
||||
client.subscribe(callback)
|
||||
|
||||
const connectPromise = client.connect()
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
await connectPromise
|
||||
|
||||
client.reset()
|
||||
|
||||
expect(client.isConnected()).toBe(false)
|
||||
})
|
||||
|
||||
it('sends ping messages via heartbeat', async () => {
|
||||
const connectPromise = client.connect()
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
await connectPromise
|
||||
|
||||
const ws = (client as unknown as { ws: MockWebSocket }).ws
|
||||
|
||||
// Advance past ping interval (30s)
|
||||
await vi.advanceTimersByTimeAsync(31000)
|
||||
|
||||
expect(ws.send).toHaveBeenCalledWith(JSON.stringify({ type: 'ping' }))
|
||||
})
|
||||
|
||||
it('disconnect prevents reconnection after abnormal close', async () => {
|
||||
const connectPromise = client.connect()
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
await connectPromise
|
||||
|
||||
// Disconnect explicitly — should prevent future reconnections
|
||||
const states: string[] = []
|
||||
client.onConnectionStateChange((s) => states.push(s))
|
||||
client.disconnect()
|
||||
|
||||
expect(states).toContain('disconnecting')
|
||||
})
|
||||
|
||||
it('handles close event with normal closure code', async () => {
|
||||
const connectPromise = client.connect()
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
await connectPromise
|
||||
|
||||
const ws = (client as unknown as { ws: MockWebSocket }).ws
|
||||
|
||||
// Simulate normal close — should still try to reconnect (shouldReconnect is true)
|
||||
ws.readyState = MockWebSocket.CLOSED
|
||||
ws.onclose?.(new CloseEvent('close', { code: 1000, wasClean: true }))
|
||||
|
||||
// After close, state transitions to disconnected
|
||||
// Then reconnection happens automatically (mock auto-opens)
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
|
||||
// Client should have attempted reconnect (state went through disconnected → connecting → connected)
|
||||
expect(client.state).toBe('connected')
|
||||
})
|
||||
|
||||
it('heartbeat detects stale connection after 5 minutes', async () => {
|
||||
const connectPromise = client.connect()
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
await connectPromise
|
||||
|
||||
const ws = (client as unknown as { ws: MockWebSocket }).ws
|
||||
const closeSpy = ws.close
|
||||
|
||||
// Advance 5+ minutes without any messages
|
||||
await vi.advanceTimersByTimeAsync(310000)
|
||||
|
||||
// Heartbeat should have closed the stale connection
|
||||
expect(closeSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('state getter returns current connection state', () => {
|
||||
expect(client.state).toBe('disconnected')
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyDataPatch', () => {
|
||||
it('returns original data for empty patch', () => {
|
||||
const data = { a: 1, b: 2 }
|
||||
const result = applyDataPatch(data, [])
|
||||
expect(result).toBe(data)
|
||||
})
|
||||
|
||||
it('returns original data for non-array patch', () => {
|
||||
const data = { a: 1 }
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = applyDataPatch(data, null as any)
|
||||
expect(result).toBe(data)
|
||||
})
|
||||
|
||||
it('applies valid patch operations', () => {
|
||||
const data = { name: 'test', count: 0 }
|
||||
const patch: import('../../types/api').PatchOperation[] = [{ op: 'replace', path: '/count', value: 5 }]
|
||||
const result = applyDataPatch(data, patch)
|
||||
// The mock returns { ...data, patched: true }
|
||||
expect(result).toHaveProperty('patched', true)
|
||||
})
|
||||
|
||||
it('returns original data when patch application throws', async () => {
|
||||
// Override mock to throw
|
||||
const { applyPatch: mockApplyPatch } = await import('fast-json-patch')
|
||||
vi.mocked(mockApplyPatch).mockImplementationOnce(() => {
|
||||
throw new Error('Invalid patch')
|
||||
})
|
||||
|
||||
const data = { value: 42 }
|
||||
const patch: import('../../types/api').PatchOperation[] = [{ op: 'replace', path: '/invalid', value: 0 }]
|
||||
const result = applyDataPatch(data, patch)
|
||||
expect(result).toBe(data)
|
||||
})
|
||||
})
|
||||
137
neode-ui/src/api/container-client.ts
Normal file
137
neode-ui/src/api/container-client.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
// Container management API client
|
||||
// Extends RPC client with container-specific methods
|
||||
|
||||
import { rpcClient } from './rpc-client'
|
||||
|
||||
export interface ContainerStatus {
|
||||
id: string
|
||||
name: string
|
||||
state: 'created' | 'running' | 'stopped' | 'exited' | 'paused' | 'unknown'
|
||||
image: string
|
||||
created: string
|
||||
ports: string[]
|
||||
lan_address?: string // Launch URL for the app's UI
|
||||
}
|
||||
|
||||
export interface ContainerAppInfo {
|
||||
id: string
|
||||
name: string
|
||||
version: string
|
||||
status: ContainerStatus
|
||||
health: 'healthy' | 'unhealthy' | 'unknown' | 'starting'
|
||||
}
|
||||
|
||||
export interface BundledAppConfig {
|
||||
id: string
|
||||
name: string
|
||||
image: string
|
||||
ports: { host: number; container: number }[]
|
||||
volumes: { host: string; container: string }[]
|
||||
}
|
||||
|
||||
export const containerClient = {
|
||||
/**
|
||||
* Install a container app from a manifest file
|
||||
*/
|
||||
async installApp(manifestPath: string): Promise<string> {
|
||||
return rpcClient.call<string>({
|
||||
method: 'container-install',
|
||||
params: { manifest_path: manifestPath },
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Start a container
|
||||
*/
|
||||
async startContainer(appId: string): Promise<void> {
|
||||
return rpcClient.call<void>({
|
||||
method: 'container-start',
|
||||
params: { app_id: appId },
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Stop a container
|
||||
*/
|
||||
async stopContainer(appId: string): Promise<void> {
|
||||
return rpcClient.call<void>({
|
||||
method: 'container-stop',
|
||||
params: { app_id: appId },
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove a container
|
||||
*/
|
||||
async removeContainer(appId: string): Promise<void> {
|
||||
return rpcClient.call<void>({
|
||||
method: 'container-remove',
|
||||
params: { app_id: appId },
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Get container status
|
||||
*/
|
||||
async getContainerStatus(appId: string): Promise<ContainerStatus> {
|
||||
return rpcClient.call<ContainerStatus>({
|
||||
method: 'container-status',
|
||||
params: { app_id: appId },
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Get container logs
|
||||
*/
|
||||
async getContainerLogs(appId: string, lines: number = 100): Promise<string[]> {
|
||||
return rpcClient.call<string[]>({
|
||||
method: 'container-logs',
|
||||
params: { app_id: appId, lines },
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* List all containers
|
||||
*/
|
||||
async listContainers(): Promise<ContainerStatus[]> {
|
||||
return rpcClient.call<ContainerStatus[]>({
|
||||
method: 'container-list',
|
||||
params: {},
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Get health status for all containers
|
||||
*/
|
||||
async getHealthStatus(): Promise<Record<string, string>> {
|
||||
return rpcClient.call<Record<string, string>>({
|
||||
method: 'container-health',
|
||||
params: {},
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Start a bundled app (creates container if needed, then starts it)
|
||||
*/
|
||||
async startBundledApp(app: BundledAppConfig): Promise<void> {
|
||||
return rpcClient.call<void>({
|
||||
method: 'bundled-app-start',
|
||||
params: {
|
||||
app_id: app.id,
|
||||
image: app.image,
|
||||
ports: app.ports,
|
||||
volumes: app.volumes,
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Stop a bundled app
|
||||
*/
|
||||
async stopBundledApp(appId: string): Promise<void> {
|
||||
return rpcClient.call<void>({
|
||||
method: 'bundled-app-stop',
|
||||
params: { app_id: appId },
|
||||
})
|
||||
},
|
||||
}
|
||||
231
neode-ui/src/api/filebrowser-client.ts
Normal file
231
neode-ui/src/api/filebrowser-client.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
export interface FileBrowserItem {
|
||||
name: string
|
||||
path: string
|
||||
size: number
|
||||
modified: string
|
||||
isDir: boolean
|
||||
type: string
|
||||
extension: string
|
||||
}
|
||||
|
||||
interface FileBrowserListResponse {
|
||||
items: FileBrowserItem[]
|
||||
numDirs: number
|
||||
numFiles: number
|
||||
sorting: { by: string; asc: boolean }
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a path: resolve `.` and `..`, reject traversal outside root.
|
||||
* Always returns a path starting with `/` and never containing `..`.
|
||||
*/
|
||||
export function sanitizePath(path: string): string {
|
||||
const segments = path.split('/').filter(Boolean)
|
||||
const resolved: string[] = []
|
||||
|
||||
for (const seg of segments) {
|
||||
if (seg === '.') continue
|
||||
if (seg === '..') {
|
||||
resolved.pop() // go up one level, but never past root
|
||||
} else {
|
||||
resolved.push(seg)
|
||||
}
|
||||
}
|
||||
|
||||
return '/' + resolved.join('/')
|
||||
}
|
||||
|
||||
class FileBrowserClient {
|
||||
private token: string | null = null
|
||||
private baseUrl: string
|
||||
|
||||
constructor() {
|
||||
this.baseUrl = `${window.location.origin}/app/filebrowser`
|
||||
}
|
||||
|
||||
get isAuthenticated(): boolean {
|
||||
return this.token !== null
|
||||
}
|
||||
|
||||
async login(username = 'admin', password = 'admin'): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${this.baseUrl}/api/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
if (!res.ok) return false
|
||||
const text = await res.text()
|
||||
// FileBrowser returns the JWT as a plain string (possibly quoted)
|
||||
this.token = text.replace(/^"|"$/g, '')
|
||||
// Store token as cookie for img/video/audio src requests (avoids token in URL)
|
||||
document.cookie = `auth=${this.token}; path=/app/filebrowser; SameSite=Strict`
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
const h: Record<string, string> = {}
|
||||
if (this.token) h['X-Auth'] = this.token
|
||||
return h
|
||||
}
|
||||
|
||||
async listDirectory(path: string): Promise<FileBrowserItem[]> {
|
||||
const safePath = sanitizePath(path)
|
||||
const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, {
|
||||
headers: this.headers(),
|
||||
})
|
||||
if (!res.ok) throw new Error(`Failed to list directory: ${res.status}`)
|
||||
const data: FileBrowserListResponse = await res.json()
|
||||
return (data.items || []).map((item) => ({
|
||||
...item,
|
||||
extension: item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : '',
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use fetchBlobUrl() instead to avoid exposing tokens in URLs.
|
||||
* Returns a plain URL (no token in query string).
|
||||
*/
|
||||
downloadUrl(path: string): string {
|
||||
const safePath = sanitizePath(path)
|
||||
return `${this.baseUrl}/api/raw${safePath}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a file as a blob URL using header-based auth (no token in URL).
|
||||
* Use this for img/video/audio src attributes and download links.
|
||||
*/
|
||||
async fetchBlobUrl(path: string): Promise<string> {
|
||||
const safePath = sanitizePath(path)
|
||||
const res = await fetch(`${this.baseUrl}/api/raw${safePath}`, {
|
||||
headers: this.headers(),
|
||||
})
|
||||
if (!res.ok) throw new Error(`Failed to fetch file: ${res.status}`)
|
||||
const blob = await res.blob()
|
||||
return URL.createObjectURL(blob)
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a file download using header-based auth (no token in URL).
|
||||
*/
|
||||
async downloadFile(path: string): Promise<void> {
|
||||
const blobUrl = await this.fetchBlobUrl(path)
|
||||
const filename = path.split('/').pop() || 'download'
|
||||
const a = document.createElement('a')
|
||||
a.href = blobUrl
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
}
|
||||
|
||||
async upload(dirPath: string, file: File): Promise<void> {
|
||||
const sanitized = sanitizePath(dirPath)
|
||||
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
|
||||
const encodedName = encodeURIComponent(file.name)
|
||||
const res = await fetch(
|
||||
`${this.baseUrl}/api/resources${safePath}${encodedName}?override=true`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: file,
|
||||
},
|
||||
)
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
throw new Error(`Upload failed (${res.status}): ${text}`)
|
||||
}
|
||||
}
|
||||
|
||||
async createFolder(parentPath: string, name: string): Promise<void> {
|
||||
const sanitized = sanitizePath(parentPath)
|
||||
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
|
||||
const sanitizedName = name.replace(/\.\./g, '').replace(/\//g, '')
|
||||
const res = await fetch(`${this.baseUrl}/api/resources${safePath}${sanitizedName}/`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
})
|
||||
if (!res.ok) throw new Error(`Create folder failed: ${res.status}`)
|
||||
}
|
||||
|
||||
async deleteItem(path: string): Promise<void> {
|
||||
const safePath = sanitizePath(path)
|
||||
const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, {
|
||||
method: 'DELETE',
|
||||
headers: this.headers(),
|
||||
})
|
||||
if (!res.ok) throw new Error(`Delete failed: ${res.status}`)
|
||||
}
|
||||
|
||||
async getUsage(): Promise<{ totalSize: number; folderCount: number; fileCount: number }> {
|
||||
if (!this.isAuthenticated) {
|
||||
const ok = await this.login()
|
||||
if (!ok) return { totalSize: 0, folderCount: 0, fileCount: 0 }
|
||||
}
|
||||
const res = await fetch(`${this.baseUrl}/api/resources/`, {
|
||||
headers: this.headers(),
|
||||
})
|
||||
if (!res.ok) return { totalSize: 0, folderCount: 0, fileCount: 0 }
|
||||
const data: FileBrowserListResponse = await res.json()
|
||||
const items = data.items || []
|
||||
const folderCount = items.filter(i => i.isDir).length
|
||||
const fileCount = items.filter(i => !i.isDir).length
|
||||
const totalSize = items.reduce((sum, i) => sum + (i.size || 0), 0)
|
||||
return { totalSize, folderCount, fileCount }
|
||||
}
|
||||
|
||||
private static TEXT_EXTENSIONS = new Set([
|
||||
'txt', 'md', 'json', 'csv', 'log', 'conf', 'yaml', 'yml', 'toml', 'xml',
|
||||
'html', 'css', 'js', 'ts', 'py', 'sh', 'bash', 'env', 'ini', 'cfg',
|
||||
'sql', 'rs', 'go', 'java', 'c', 'h', 'cpp', 'hpp', 'rb', 'php',
|
||||
'dockerfile', 'makefile', 'gitignore', 'editorconfig',
|
||||
])
|
||||
|
||||
isTextFile(path: string): boolean {
|
||||
const ext = path.includes('.') ? path.split('.').pop()!.toLowerCase() : ''
|
||||
const name = path.split('/').pop()?.toLowerCase() || ''
|
||||
return FileBrowserClient.TEXT_EXTENSIONS.has(ext) || FileBrowserClient.TEXT_EXTENSIONS.has(name)
|
||||
}
|
||||
|
||||
async readFileAsText(path: string, maxBytes = 102400): Promise<{ content: string; truncated: boolean; size: number }> {
|
||||
if (!this.isAuthenticated) {
|
||||
const ok = await this.login()
|
||||
if (!ok) throw new Error('FileBrowser authentication failed')
|
||||
}
|
||||
if (!this.isTextFile(path)) {
|
||||
throw new Error(`Cannot read binary file: ${path}`)
|
||||
}
|
||||
const safePath = sanitizePath(path)
|
||||
const res = await fetch(`${this.baseUrl}/api/raw${safePath}`, {
|
||||
headers: this.headers(),
|
||||
})
|
||||
if (!res.ok) throw new Error(`Failed to read file: ${res.status}`)
|
||||
const blob = await res.blob()
|
||||
const size = blob.size
|
||||
const truncated = size > maxBytes
|
||||
const slice = truncated ? blob.slice(0, maxBytes) : blob
|
||||
const content = await slice.text()
|
||||
return { content, truncated, size }
|
||||
}
|
||||
|
||||
async rename(oldPath: string, newName: string): Promise<void> {
|
||||
const safePath = sanitizePath(oldPath)
|
||||
const dir = safePath.substring(0, safePath.lastIndexOf('/') + 1)
|
||||
const sanitizedName = newName.replace(/\.\./g, '').replace(/\//g, '')
|
||||
const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...this.headers(),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ destination: `${dir}${sanitizedName}` }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`Rename failed: ${res.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
export const fileBrowserClient = new FileBrowserClient()
|
||||
699
neode-ui/src/api/rpc-client.ts
Normal file
699
neode-ui/src/api/rpc-client.ts
Normal file
@@ -0,0 +1,699 @@
|
||||
// RPC Client for connecting to Archipelago backend
|
||||
|
||||
export interface RPCOptions {
|
||||
method: string
|
||||
params?: Record<string, unknown>
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
export interface RPCResponse<T> {
|
||||
result?: T
|
||||
error?: {
|
||||
code: number
|
||||
message: string
|
||||
data?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
function getCsrfToken(): string | null {
|
||||
const match = document.cookie.match(/(?:^|;\s*)csrf_token=([^;]+)/)
|
||||
return match ? match[1]! : null
|
||||
}
|
||||
|
||||
class RPCClient {
|
||||
private baseUrl: string
|
||||
|
||||
constructor(baseUrl: string = '/rpc/v1') {
|
||||
this.baseUrl = baseUrl
|
||||
}
|
||||
|
||||
async call<T>(options: RPCOptions): Promise<T> {
|
||||
const { method, params = {}, timeout = 30000 } = options
|
||||
const maxRetries = 3
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout)
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
const csrfToken = getCsrfToken()
|
||||
if (csrfToken) {
|
||||
headers['X-CSRF-Token'] = csrfToken
|
||||
}
|
||||
|
||||
const response = await fetch(this.baseUrl, {
|
||||
method: 'POST',
|
||||
credentials: 'include', // Important for session cookies
|
||||
headers,
|
||||
body: JSON.stringify({ method, params }),
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
if (!response.ok) {
|
||||
// Session expired — redirect to login
|
||||
if (response.status === 401 && method !== 'auth.login') {
|
||||
window.location.href = '/login'
|
||||
throw new Error('Session expired')
|
||||
}
|
||||
const err = new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
const isRetryable = response.status === 502 || response.status === 503
|
||||
if (isRetryable && attempt < maxRetries - 1) {
|
||||
await new Promise((r) => setTimeout(r, 600 * (attempt + 1)))
|
||||
continue
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
const data: RPCResponse<T> = await response.json()
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error.message || 'RPC Error')
|
||||
}
|
||||
|
||||
return data.result as T
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId)
|
||||
if (error instanceof Error) {
|
||||
if (error.name === 'AbortError') {
|
||||
const timeoutErr = new Error('Request timeout')
|
||||
if (attempt < maxRetries - 1) {
|
||||
await new Promise((r) => setTimeout(r, 600 * (attempt + 1)))
|
||||
continue
|
||||
}
|
||||
throw timeoutErr
|
||||
}
|
||||
const msg = error.message
|
||||
const isRetryable = /502|503|Bad Gateway|fetch|network/i.test(msg)
|
||||
if (isRetryable && attempt < maxRetries - 1) {
|
||||
await new Promise((r) => setTimeout(r, 600 * (attempt + 1)))
|
||||
continue
|
||||
}
|
||||
throw error
|
||||
}
|
||||
throw new Error('Unknown error occurred')
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Request failed after retries')
|
||||
}
|
||||
|
||||
// Convenience methods
|
||||
async login(password: string): Promise<{ requires_totp?: boolean } | null> {
|
||||
return this.call({
|
||||
method: 'auth.login',
|
||||
params: {
|
||||
password,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async loginTotp(code: string): Promise<{ success: boolean }> {
|
||||
return this.call({ method: 'auth.login.totp', params: { code } })
|
||||
}
|
||||
|
||||
async loginBackup(code: string): Promise<{ success: boolean }> {
|
||||
return this.call({ method: 'auth.login.backup', params: { code } })
|
||||
}
|
||||
|
||||
async totpSetupBegin(password: string): Promise<{
|
||||
qr_svg: string
|
||||
secret_base32: string
|
||||
pending_token: string
|
||||
}> {
|
||||
return this.call({ method: 'auth.totp.setup.begin', params: { password } })
|
||||
}
|
||||
|
||||
async totpSetupConfirm(params: {
|
||||
code: string
|
||||
password: string
|
||||
pendingToken: string
|
||||
}): Promise<{ enabled: boolean; backup_codes: string[] }> {
|
||||
return this.call({ method: 'auth.totp.setup.confirm', params })
|
||||
}
|
||||
|
||||
async totpDisable(password: string, code: string): Promise<{ disabled: boolean }> {
|
||||
return this.call({ method: 'auth.totp.disable', params: { password, code } })
|
||||
}
|
||||
|
||||
async totpStatus(): Promise<{ enabled: boolean }> {
|
||||
return this.call({ method: 'auth.totp.status', params: {} })
|
||||
}
|
||||
|
||||
async changePassword(params: {
|
||||
currentPassword: string
|
||||
newPassword: string
|
||||
alsoChangeSsh?: boolean
|
||||
}): Promise<{ success: boolean }> {
|
||||
return this.call({
|
||||
method: 'auth.changePassword',
|
||||
params: {
|
||||
currentPassword: params.currentPassword,
|
||||
newPassword: params.newPassword,
|
||||
alsoChangeSsh: params.alsoChangeSsh ?? true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
return this.call({
|
||||
method: 'auth.logout',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async completeOnboarding(): Promise<boolean> {
|
||||
return this.call({
|
||||
method: 'auth.onboardingComplete',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async isOnboardingComplete(): Promise<boolean> {
|
||||
return this.call({
|
||||
method: 'auth.isOnboardingComplete',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async resetOnboarding(): Promise<boolean> {
|
||||
return this.call({
|
||||
method: 'auth.resetOnboarding',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async getNodeDid(): Promise<{ did: string; pubkey: string }> {
|
||||
return this.call({
|
||||
method: 'node.did',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async signChallenge(challenge: string): Promise<{ signature: string }> {
|
||||
return this.call({
|
||||
method: 'node.signChallenge',
|
||||
params: { challenge },
|
||||
})
|
||||
}
|
||||
|
||||
async createBackup(passphrase: string): Promise<{
|
||||
version: number
|
||||
did: string
|
||||
pubkey: string
|
||||
kid: string
|
||||
encrypted: boolean
|
||||
blob: string
|
||||
timestamp: string
|
||||
}> {
|
||||
return this.call({
|
||||
method: 'node.createBackup',
|
||||
params: { passphrase },
|
||||
})
|
||||
}
|
||||
|
||||
async resolveDid(did?: string): Promise<Record<string, unknown>> {
|
||||
return this.call({
|
||||
method: 'identity.resolve-did',
|
||||
params: did ? { did } : {},
|
||||
})
|
||||
}
|
||||
|
||||
async createPresentation(params: {
|
||||
holderId: string
|
||||
credentialIds: string[]
|
||||
}): Promise<Record<string, unknown>> {
|
||||
return this.call({
|
||||
method: 'identity.create-presentation',
|
||||
params: { holder_id: params.holderId, credential_ids: params.credentialIds },
|
||||
})
|
||||
}
|
||||
|
||||
async verifyPresentation(presentation: Record<string, unknown>): Promise<{
|
||||
valid: boolean
|
||||
holder_valid: boolean
|
||||
credentials: Array<{ id: string; valid: boolean; revoked: boolean }>
|
||||
}> {
|
||||
return this.call({
|
||||
method: 'identity.verify-presentation',
|
||||
params: { presentation },
|
||||
})
|
||||
}
|
||||
|
||||
async createPsbt(params: {
|
||||
outputs: Array<{ address: string; amount_sats: number }>
|
||||
feeRateSatPerVbyte?: number
|
||||
}): Promise<{
|
||||
psbt_base64: string
|
||||
change_output_index: number
|
||||
total_amount_sats: number
|
||||
fee_rate_sat_per_vbyte: number
|
||||
}> {
|
||||
return this.call({
|
||||
method: 'lnd.create-psbt',
|
||||
params: {
|
||||
outputs: params.outputs,
|
||||
fee_rate_sat_per_vbyte: params.feeRateSatPerVbyte ?? 10,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async finalizePsbt(signedPsbtBase64: string): Promise<{
|
||||
raw_final_tx: string
|
||||
broadcast: boolean
|
||||
}> {
|
||||
return this.call({
|
||||
method: 'lnd.finalize-psbt',
|
||||
params: { signed_psbt_base64: signedPsbtBase64 },
|
||||
})
|
||||
}
|
||||
|
||||
async publishNostrIdentity(): Promise<{ event_id: string; success: number; failed: number }> {
|
||||
return this.call({
|
||||
method: 'node.nostr-publish',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async getNostrPubkey(): Promise<{ nostr_pubkey: string; nostr_npub?: string }> {
|
||||
return this.call({
|
||||
method: 'node.nostr-pubkey',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async listPeers(): Promise<{ peers: Array<{ onion: string; pubkey: string; name?: string }> }> {
|
||||
return this.call({
|
||||
method: 'node-list-peers',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async addPeer(params: { onion: string; pubkey: string; name?: string }): Promise<{ peers: unknown[] }> {
|
||||
return this.call({
|
||||
method: 'node-add-peer',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
async removePeer(pubkey: string): Promise<{ peers: unknown[] }> {
|
||||
return this.call({
|
||||
method: 'node-remove-peer',
|
||||
params: { pubkey },
|
||||
})
|
||||
}
|
||||
|
||||
async sendMessageToPeer(onion: string, message: string): Promise<{ ok: boolean; sent_to: string }> {
|
||||
return this.call({
|
||||
method: 'node-send-message',
|
||||
params: { onion, message },
|
||||
timeout: 90000,
|
||||
})
|
||||
}
|
||||
|
||||
async checkPeerReachable(onion: string): Promise<{ onion: string; reachable: boolean }> {
|
||||
return this.call({
|
||||
method: 'node-check-peer',
|
||||
params: { onion },
|
||||
timeout: 35000,
|
||||
})
|
||||
}
|
||||
|
||||
async getReceivedMessages(): Promise<{ messages: Array<{ from_pubkey: string; message: string; timestamp: string }> }> {
|
||||
return this.call({
|
||||
method: 'node-messages-received',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async discoverNodes(): Promise<{ nodes: Array<{ did: string; onion: string; pubkey: string; node_address: string }> }> {
|
||||
return this.call({
|
||||
method: 'node-nostr-discover',
|
||||
params: {},
|
||||
timeout: 20000,
|
||||
})
|
||||
}
|
||||
|
||||
async getTorAddress(): Promise<{ tor_address: string | null }> {
|
||||
return this.call({
|
||||
method: 'node.tor-address',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async torListServices(): Promise<{ services: Array<{ name: string; local_port: number; onion_address: string | null; enabled: boolean }> }> {
|
||||
return this.call({ method: 'tor.list-services' })
|
||||
}
|
||||
|
||||
async torRotateService(name: string): Promise<{ rotated: boolean; name: string; old_onion: string | null; new_onion: string | null; transition_hours: number }> {
|
||||
return this.call({ method: 'tor.rotate-service', params: { name } })
|
||||
}
|
||||
|
||||
async torToggleApp(appId: string, enabled: boolean): Promise<{ app_id: string; enabled: boolean; changed: boolean; onion_address: string | null }> {
|
||||
return this.call({ method: 'tor.toggle-app', params: { app_id: appId, enabled } })
|
||||
}
|
||||
|
||||
async torCleanupRotated(): Promise<{ cleaned: string[]; count: number }> {
|
||||
return this.call({ method: 'tor.cleanup-rotated' })
|
||||
}
|
||||
|
||||
async verifyNostrRevoked(): Promise<{
|
||||
revoked: boolean
|
||||
nostr_pubkey: string
|
||||
latest_content?: string
|
||||
error?: string
|
||||
}> {
|
||||
return this.call({
|
||||
method: 'node-nostr-verify-revoked',
|
||||
params: {},
|
||||
timeout: 25000,
|
||||
})
|
||||
}
|
||||
|
||||
async echo(message: string): Promise<string> {
|
||||
return this.call({
|
||||
method: 'server.echo',
|
||||
params: { message },
|
||||
})
|
||||
}
|
||||
|
||||
async getSystemTime(): Promise<{ now: string; uptime: number }> {
|
||||
return this.call({
|
||||
method: 'server.time',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async getMetrics(): Promise<Record<string, unknown>> {
|
||||
return this.call({
|
||||
method: 'server.metrics',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async updateServer(marketplaceUrl: string): Promise<'updating' | 'no-updates'> {
|
||||
return this.call({
|
||||
method: 'server.update',
|
||||
params: { 'marketplace-url': marketplaceUrl },
|
||||
})
|
||||
}
|
||||
|
||||
async detectUsbDevices(): Promise<{
|
||||
devices: Array<{
|
||||
type: string
|
||||
vendor_id: string
|
||||
product_id: string
|
||||
manufacturer: string
|
||||
product: string
|
||||
}>
|
||||
}> {
|
||||
return this.call({
|
||||
method: 'system.detect-usb-devices',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async restartServer(): Promise<void> {
|
||||
return this.call({
|
||||
method: 'server.restart',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async shutdownServer(): Promise<void> {
|
||||
return this.call({
|
||||
method: 'server.shutdown',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async installPackage(id: string, marketplaceUrl: string, version: string): Promise<string> {
|
||||
return this.call({
|
||||
method: 'package.install',
|
||||
params: { id, 'marketplace-url': marketplaceUrl, version },
|
||||
})
|
||||
}
|
||||
|
||||
async uninstallPackage(id: string): Promise<void> {
|
||||
return this.call({
|
||||
method: 'package.uninstall',
|
||||
params: { id },
|
||||
})
|
||||
}
|
||||
|
||||
async startPackage(id: string): Promise<void> {
|
||||
return this.call({
|
||||
method: 'package.start',
|
||||
params: { id },
|
||||
})
|
||||
}
|
||||
|
||||
async stopPackage(id: string): Promise<void> {
|
||||
return this.call({
|
||||
method: 'package.stop',
|
||||
params: { id },
|
||||
})
|
||||
}
|
||||
|
||||
async restartPackage(id: string): Promise<void> {
|
||||
return this.call({
|
||||
method: 'package.restart',
|
||||
params: { id },
|
||||
})
|
||||
}
|
||||
|
||||
async getMarketplace(url: string): Promise<Record<string, unknown>> {
|
||||
return this.call({
|
||||
method: 'marketplace.get',
|
||||
params: { url },
|
||||
})
|
||||
}
|
||||
|
||||
// Federation
|
||||
async federationInvite(): Promise<{ code: string; did: string; onion: string }> {
|
||||
return this.call({
|
||||
method: 'federation.invite',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async federationJoin(code: string): Promise<{
|
||||
joined: boolean
|
||||
node: { did: string; onion: string; pubkey: string; trust_level: string }
|
||||
}> {
|
||||
return this.call({
|
||||
method: 'federation.join',
|
||||
params: { code },
|
||||
})
|
||||
}
|
||||
|
||||
async federationListNodes(): Promise<{
|
||||
nodes: Array<{
|
||||
did: string
|
||||
pubkey: string
|
||||
onion: string
|
||||
trust_level: string
|
||||
added_at: string
|
||||
name?: string
|
||||
last_seen?: string
|
||||
last_state?: {
|
||||
timestamp: string
|
||||
apps: Array<{ id: string; status: string; version?: string }>
|
||||
cpu_usage_percent?: number
|
||||
mem_used_bytes?: number
|
||||
mem_total_bytes?: number
|
||||
disk_used_bytes?: number
|
||||
disk_total_bytes?: number
|
||||
uptime_secs?: number
|
||||
tor_active?: boolean
|
||||
}
|
||||
}>
|
||||
}> {
|
||||
return this.call({
|
||||
method: 'federation.list-nodes',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async federationRemoveNode(did: string): Promise<{ removed: boolean; nodes_remaining: number }> {
|
||||
return this.call({
|
||||
method: 'federation.remove-node',
|
||||
params: { did },
|
||||
})
|
||||
}
|
||||
|
||||
async federationSetTrust(
|
||||
did: string,
|
||||
trustLevel: 'trusted' | 'observer' | 'untrusted',
|
||||
): Promise<{ updated: boolean; did: string; trust_level: string }> {
|
||||
return this.call({
|
||||
method: 'federation.set-trust',
|
||||
params: { did, trust_level: trustLevel },
|
||||
})
|
||||
}
|
||||
|
||||
async federationSyncState(): Promise<{
|
||||
synced: number
|
||||
failed: number
|
||||
results: Array<{ did: string; status: string; apps?: number; error?: string }>
|
||||
}> {
|
||||
return this.call({
|
||||
method: 'federation.sync-state',
|
||||
params: {},
|
||||
timeout: 120000,
|
||||
})
|
||||
}
|
||||
|
||||
async federationDeployApp(params: {
|
||||
did: string
|
||||
appId: string
|
||||
version?: string
|
||||
marketplaceUrl?: string
|
||||
}): Promise<{ deployed: boolean; app_id: string; peer_did: string; peer_onion: string }> {
|
||||
return this.call({
|
||||
method: 'federation.deploy-app',
|
||||
params: {
|
||||
did: params.did,
|
||||
app_id: params.appId,
|
||||
version: params.version ?? 'latest',
|
||||
marketplace_url: params.marketplaceUrl ?? '',
|
||||
},
|
||||
timeout: 180000,
|
||||
})
|
||||
}
|
||||
|
||||
// VPN
|
||||
async vpnStatus(): Promise<{
|
||||
connected: boolean
|
||||
provider?: string
|
||||
interface?: string
|
||||
ip_address?: string
|
||||
hostname?: string
|
||||
peers_connected: number
|
||||
bytes_in: number
|
||||
bytes_out: number
|
||||
configured: boolean
|
||||
configured_provider: string
|
||||
}> {
|
||||
return this.call({
|
||||
method: 'vpn.status',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async vpnConfigure(params: {
|
||||
provider: 'tailscale' | 'wireguard'
|
||||
auth_key?: string
|
||||
address?: string
|
||||
dns?: string
|
||||
peer?: {
|
||||
public_key: string
|
||||
endpoint: string
|
||||
allowed_ips?: string
|
||||
persistent_keepalive?: number
|
||||
}
|
||||
}): Promise<{ configured: boolean; provider: string; public_key?: string; address?: string }> {
|
||||
return this.call({
|
||||
method: 'vpn.configure',
|
||||
params,
|
||||
timeout: 60000,
|
||||
})
|
||||
}
|
||||
|
||||
async vpnDisconnect(): Promise<{ disconnected: boolean }> {
|
||||
return this.call({
|
||||
method: 'vpn.disconnect',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
// Marketplace
|
||||
async marketplaceDiscover(): Promise<{
|
||||
apps: Array<{
|
||||
manifest: {
|
||||
app_id: string
|
||||
name: string
|
||||
version: string
|
||||
description: { short: string; long: string } | string
|
||||
author: { name: string; did: string; nostr_pubkey: string }
|
||||
container: { image: string; ports: Array<{ container: number; host: number }>; }
|
||||
category: string
|
||||
icon_url: string
|
||||
repo_url: string
|
||||
license: string
|
||||
}
|
||||
trust_score: number
|
||||
trust_tier: string
|
||||
relay_count: number
|
||||
first_seen: string
|
||||
nostr_pubkey: string
|
||||
}>
|
||||
relay_count: number
|
||||
}> {
|
||||
return this.call({
|
||||
method: 'marketplace.discover',
|
||||
params: {},
|
||||
timeout: 30000,
|
||||
})
|
||||
}
|
||||
|
||||
// DNS
|
||||
async dnsStatus(): Promise<{
|
||||
provider: string
|
||||
servers: string[]
|
||||
doh_enabled: boolean
|
||||
doh_url: string | null
|
||||
resolv_conf_servers: string[]
|
||||
}> {
|
||||
return this.call({
|
||||
method: 'network.dns-status',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async configureDns(params: {
|
||||
provider: 'system' | 'cloudflare' | 'google' | 'quad9' | 'mullvad' | 'custom'
|
||||
servers?: string[]
|
||||
}): Promise<{
|
||||
ok: boolean
|
||||
provider: string
|
||||
servers: string[]
|
||||
doh_enabled: boolean
|
||||
doh_url: string | null
|
||||
}> {
|
||||
return this.call({
|
||||
method: 'network.configure-dns',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
// Disk management
|
||||
async diskStatus(): Promise<{
|
||||
used_bytes: number
|
||||
total_bytes: number
|
||||
free_bytes: number
|
||||
used_percent: number
|
||||
level: 'ok' | 'warning' | 'critical'
|
||||
}> {
|
||||
return this.call({ method: 'system.disk-status' })
|
||||
}
|
||||
|
||||
async diskCleanup(): Promise<{
|
||||
freed_bytes: number
|
||||
freed_human: string
|
||||
actions: string[]
|
||||
}> {
|
||||
return this.call({
|
||||
method: 'system.disk-cleanup',
|
||||
timeout: 60000,
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const rpcClient = new RPCClient()
|
||||
|
||||
428
neode-ui/src/api/websocket.ts
Normal file
428
neode-ui/src/api/websocket.ts
Normal file
@@ -0,0 +1,428 @@
|
||||
// WebSocket handler for real-time updates
|
||||
|
||||
import type { Update, PatchOperation } from '../types/api'
|
||||
import { applyPatch, type Operation } from 'fast-json-patch'
|
||||
|
||||
export type ConnectionState = 'connecting' | 'connected' | 'disconnecting' | 'disconnected'
|
||||
|
||||
type WebSocketCallback = (update: Update) => void
|
||||
type ConnectionStateCallback = (state: ConnectionState) => void
|
||||
|
||||
export class WebSocketClient {
|
||||
private ws: WebSocket | null = null
|
||||
private callbacks: Set<WebSocketCallback> = new Set()
|
||||
private connectionStateCallbacks: Set<ConnectionStateCallback> = new Set()
|
||||
private reconnectAttempts = 0
|
||||
private maxReconnectAttempts = 10
|
||||
private reconnectDelay = 1000
|
||||
private maxReconnectDelay = 30000
|
||||
private shouldReconnect = true
|
||||
private url: string
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private visibilityChangeHandler: (() => void) | null = null
|
||||
private onlineHandler: (() => void) | null = null
|
||||
private heartbeatTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private pingTimer: ReturnType<typeof setInterval> | null = null
|
||||
private lastMessageTime: number = Date.now()
|
||||
private heartbeatInterval = 10000 // Check connection every 10 seconds
|
||||
private pingInterval = 30000 // Send ping every 30 seconds
|
||||
private _state: ConnectionState = 'disconnected'
|
||||
|
||||
constructor(url: string = '/ws/db') {
|
||||
this.url = url
|
||||
this.setupBrowserEventHandlers()
|
||||
}
|
||||
|
||||
private setupBrowserEventHandlers(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
// Handle page visibility changes (tab switching, browser minimizing)
|
||||
this.visibilityChangeHandler = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Page became visible, checking connection...')
|
||||
// Only reconnect if we haven't been explicitly disconnected
|
||||
if (this.shouldReconnect && (!this.ws || this.ws.readyState !== WebSocket.OPEN)) {
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Connection lost while hidden, reconnecting...')
|
||||
this.reconnectAttempts = 0
|
||||
this.connect().catch(err => {
|
||||
if (import.meta.env.DEV) console.error('[WebSocket] Failed to reconnect on visibility change:', err)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener('visibilitychange', this.visibilityChangeHandler)
|
||||
|
||||
// Handle network online/offline events
|
||||
this.onlineHandler = () => {
|
||||
// Only reconnect if we haven't been explicitly disconnected
|
||||
if (!this.shouldReconnect) return
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Network came online, reconnecting...')
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
this.reconnectAttempts = 0
|
||||
this.connect().catch(err => {
|
||||
if (import.meta.env.DEV) console.error('[WebSocket] Failed to reconnect when network came online:', err)
|
||||
})
|
||||
}
|
||||
}
|
||||
window.addEventListener('online', this.onlineHandler)
|
||||
}
|
||||
|
||||
connect(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// If already connected, resolve immediately
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Already connected, skipping')
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
// If connecting, wait for it
|
||||
if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Already connecting, waiting...')
|
||||
const checkInterval = setInterval(() => {
|
||||
if (this.ws) {
|
||||
if (this.ws.readyState === WebSocket.OPEN) {
|
||||
clearInterval(checkInterval)
|
||||
resolve()
|
||||
} else if (this.ws.readyState === WebSocket.CLOSED || this.ws.readyState === WebSocket.CLOSING) {
|
||||
clearInterval(checkInterval)
|
||||
// Connection failed or closing, will be handled by onclose
|
||||
reject(new Error('Connection closed during connect'))
|
||||
}
|
||||
} else {
|
||||
clearInterval(checkInterval)
|
||||
reject(new Error('WebSocket was cleared'))
|
||||
}
|
||||
}, 100)
|
||||
|
||||
// Timeout after 5 seconds
|
||||
setTimeout(() => {
|
||||
clearInterval(checkInterval)
|
||||
if (this.ws && this.ws.readyState !== WebSocket.OPEN) {
|
||||
reject(new Error('Connection timeout'))
|
||||
}
|
||||
}, 5000)
|
||||
return
|
||||
}
|
||||
|
||||
// Don't close existing connection if it's still active
|
||||
// Only close if it's in CLOSING or CLOSED state
|
||||
if (this.ws && (this.ws.readyState === WebSocket.CLOSING || this.ws.readyState === WebSocket.CLOSED)) {
|
||||
this.ws = null
|
||||
}
|
||||
|
||||
// If we have an active WebSocket, don't create a new one
|
||||
if (this.ws) {
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Connection exists, reusing it')
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
// Only enable reconnect if not explicitly disconnected
|
||||
// (shouldReconnect is set to false by disconnect())
|
||||
if (this.shouldReconnect !== false) {
|
||||
this.shouldReconnect = true
|
||||
}
|
||||
|
||||
// In development, Vite proxies /ws to the backend
|
||||
// In production, use the same host as the page
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const host = window.location.host
|
||||
const wsUrl = `${protocol}//${host}${this.url}`
|
||||
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Connecting to:', wsUrl)
|
||||
|
||||
this.setConnectionState('connecting')
|
||||
this.ws = new WebSocket(wsUrl)
|
||||
|
||||
// Timeout handler in case connection hangs
|
||||
const connectionTimeout = setTimeout(() => {
|
||||
if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
|
||||
if (import.meta.env.DEV) console.warn('WebSocket connection timeout, retrying...')
|
||||
this.ws.close()
|
||||
reject(new Error('Connection timeout'))
|
||||
}
|
||||
}, 3000) // 3 second timeout
|
||||
|
||||
this.ws.onopen = () => {
|
||||
clearTimeout(connectionTimeout)
|
||||
this.reconnectAttempts = 0
|
||||
this.lastMessageTime = Date.now()
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Connected successfully')
|
||||
this.setConnectionState('connected')
|
||||
this.startHeartbeat()
|
||||
resolve()
|
||||
}
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
clearTimeout(connectionTimeout)
|
||||
if (import.meta.env.DEV) console.error('[WebSocket] Connection error:', error)
|
||||
// Don't reject immediately - let onclose handle reconnection
|
||||
// This prevents errors from blocking reconnection
|
||||
}
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
this.lastMessageTime = Date.now()
|
||||
try {
|
||||
const update: Update = JSON.parse(event.data)
|
||||
this.callbacks.forEach((callback) => callback(update))
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) console.error('Failed to parse WebSocket message:', error)
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
clearTimeout(connectionTimeout)
|
||||
this.stopHeartbeat()
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Closed', { code: event.code, reason: event.reason, wasClean: event.wasClean })
|
||||
|
||||
// Notify connection state changed
|
||||
this.setConnectionState('disconnected')
|
||||
|
||||
// Clear the WebSocket reference
|
||||
this.ws = null
|
||||
|
||||
// Don't reconnect if we explicitly disconnected
|
||||
if (!this.shouldReconnect) {
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Reconnection disabled')
|
||||
return
|
||||
}
|
||||
|
||||
// Always try to reconnect unless we've exceeded max attempts
|
||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
const isHMR = event.code === 1001
|
||||
const isNormalClosure = event.code === 1000 || event.code === 1001
|
||||
const isServiceRestart = event.code === 1012
|
||||
|
||||
// Immediate reconnection for HMR, service restarts, and first attempt after abnormal closure
|
||||
const needsImmediateReconnect = isHMR || isServiceRestart || (event.code === 1006 && this.reconnectAttempts === 0)
|
||||
|
||||
const delay = needsImmediateReconnect ? 0 :
|
||||
(this.reconnectAttempts === 0 ? 100 :
|
||||
Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts), this.maxReconnectDelay))
|
||||
|
||||
if (import.meta.env.DEV) console.log(`[WebSocket] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1}/${this.maxReconnectAttempts}, code: ${event.code})`)
|
||||
|
||||
// Clear any existing reconnect timer
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
|
||||
const doReconnect = () => {
|
||||
// Check again if we should reconnect (might have been disabled)
|
||||
if (!this.shouldReconnect) {
|
||||
return
|
||||
}
|
||||
|
||||
// Don't increment attempts for expected disconnects (HMR, normal closure)
|
||||
if (!isHMR && !isNormalClosure) {
|
||||
this.reconnectAttempts++
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Attempting reconnection...')
|
||||
this.connect().catch((err) => {
|
||||
if (import.meta.env.DEV) console.error('[WebSocket] Reconnection failed:', err)
|
||||
// onclose will be called again and will retry
|
||||
})
|
||||
}
|
||||
|
||||
if (delay === 0) {
|
||||
// Immediate reconnection
|
||||
doReconnect()
|
||||
} else {
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
doReconnect()
|
||||
}, delay)
|
||||
}
|
||||
} else {
|
||||
if (import.meta.env.DEV) console.warn('[WebSocket] Max reconnection attempts reached')
|
||||
this.shouldReconnect = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
subscribe(callback: WebSocketCallback): () => void {
|
||||
this.callbacks.add(callback)
|
||||
return () => {
|
||||
this.callbacks.delete(callback)
|
||||
}
|
||||
}
|
||||
|
||||
get state(): ConnectionState {
|
||||
return this._state
|
||||
}
|
||||
|
||||
onConnectionStateChange(callback: ConnectionStateCallback): () => void {
|
||||
this.connectionStateCallbacks.add(callback)
|
||||
return () => {
|
||||
this.connectionStateCallbacks.delete(callback)
|
||||
}
|
||||
}
|
||||
|
||||
private setConnectionState(state: ConnectionState): void {
|
||||
this._state = state
|
||||
this.connectionStateCallbacks.forEach((callback) => callback(state))
|
||||
}
|
||||
|
||||
private startHeartbeat(): void {
|
||||
this.stopHeartbeat()
|
||||
|
||||
// Send ping messages every 30s
|
||||
this.pingTimer = setInterval(() => {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
this.ws.send(JSON.stringify({ type: 'ping' }))
|
||||
} catch {
|
||||
// Send failed, connection likely broken
|
||||
}
|
||||
}
|
||||
}, this.pingInterval)
|
||||
|
||||
// Check connection health every 10s
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
if (import.meta.env.DEV) console.warn('[WebSocket] Heartbeat detected closed connection')
|
||||
this.stopHeartbeat()
|
||||
return
|
||||
}
|
||||
|
||||
const timeSinceLastMessage = Date.now() - this.lastMessageTime
|
||||
|
||||
// If no message for more than 5 minutes, assume connection is stale
|
||||
if (timeSinceLastMessage > 300000) {
|
||||
if (import.meta.env.DEV) console.warn('[WebSocket] No messages for 5m, reconnecting...')
|
||||
this.ws.close()
|
||||
return
|
||||
}
|
||||
}, this.heartbeatInterval)
|
||||
}
|
||||
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer)
|
||||
this.heartbeatTimer = null
|
||||
}
|
||||
if (this.pingTimer) {
|
||||
clearInterval(this.pingTimer)
|
||||
this.pingTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.shouldReconnect = false
|
||||
this.reconnectAttempts = 0
|
||||
this.setConnectionState('disconnecting')
|
||||
this.stopHeartbeat()
|
||||
|
||||
// Clear reconnect timer
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
|
||||
if (this.ws) {
|
||||
// Remove handlers to prevent reconnection
|
||||
this.ws.onclose = null
|
||||
this.ws.onerror = null
|
||||
try {
|
||||
this.ws.close()
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('WebSocket close error', e)
|
||||
}
|
||||
this.ws = null
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.disconnect()
|
||||
this.callbacks.clear()
|
||||
|
||||
// Clean up browser event handlers
|
||||
if (this.visibilityChangeHandler) {
|
||||
document.removeEventListener('visibilitychange', this.visibilityChangeHandler)
|
||||
this.visibilityChangeHandler = null
|
||||
}
|
||||
if (this.onlineHandler) {
|
||||
window.removeEventListener('online', this.onlineHandler)
|
||||
this.onlineHandler = null
|
||||
}
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.ws?.readyState === WebSocket.OPEN
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton that persists across HMR
|
||||
let wsClientInstance: WebSocketClient | null = null
|
||||
|
||||
function getWebSocketClient(): WebSocketClient {
|
||||
if (typeof window === 'undefined') {
|
||||
// SSR - create new instance
|
||||
if (!wsClientInstance) {
|
||||
wsClientInstance = new WebSocketClient()
|
||||
}
|
||||
return wsClientInstance
|
||||
}
|
||||
|
||||
// Check if we have a persisted instance from HMR
|
||||
const existing = (window as unknown as Record<string, unknown>).__archipelago_ws_client
|
||||
if (existing && existing instanceof WebSocketClient) {
|
||||
// Check if the WebSocket is still valid
|
||||
if (existing.isConnected()) {
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Using existing connected client from HMR')
|
||||
wsClientInstance = existing
|
||||
return existing
|
||||
}
|
||||
}
|
||||
|
||||
// Create new instance
|
||||
if (!wsClientInstance) {
|
||||
wsClientInstance = new WebSocketClient()
|
||||
if (typeof window !== 'undefined') {
|
||||
;(window as unknown as Record<string, unknown>).__archipelago_ws_client = wsClientInstance
|
||||
}
|
||||
if (import.meta.env.DEV) console.debug('[WebSocket] Created new client instance')
|
||||
}
|
||||
|
||||
return wsClientInstance
|
||||
}
|
||||
|
||||
// Lazy initialization - only create when accessed
|
||||
let _wsClient: WebSocketClient | null = null
|
||||
|
||||
export const wsClient: WebSocketClient = (() => {
|
||||
if (_wsClient) {
|
||||
return _wsClient
|
||||
}
|
||||
try {
|
||||
_wsClient = getWebSocketClient()
|
||||
return _wsClient
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) console.error('[WebSocket] Error initializing client:', error)
|
||||
// Fallback to new instance
|
||||
_wsClient = new WebSocketClient()
|
||||
return _wsClient
|
||||
}
|
||||
})()
|
||||
|
||||
// Helper to apply patches to data
|
||||
export function applyDataPatch<T>(data: T, patch: PatchOperation[]): T {
|
||||
// Validate patch is an array before applying
|
||||
if (!Array.isArray(patch) || patch.length === 0) {
|
||||
if (import.meta.env.DEV) console.warn('Invalid or empty patch received, returning original data')
|
||||
return data
|
||||
}
|
||||
|
||||
try {
|
||||
const result = applyPatch(data, patch as Operation[], false, false)
|
||||
return result.newDocument as T
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) console.error('Failed to apply patch:', error, 'Patch:', patch)
|
||||
return data // Return original data on error
|
||||
}
|
||||
}
|
||||
|
||||
1
neode-ui/src/assets/vue.svg
Normal file
1
neode-ui/src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
95
neode-ui/src/components/AnimatedLogo.vue
Normal file
95
neode-ui/src/components/AnimatedLogo.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex-shrink-0 inline-block overflow-hidden"
|
||||
:class="[
|
||||
sizeClass,
|
||||
!noBorder && 'logo-gradient-border'
|
||||
]"
|
||||
>
|
||||
<!-- Neode logo - always white -->
|
||||
<svg
|
||||
class="block w-full h-full logo-svg"
|
||||
viewBox="0 0 1024 1024"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="Neode"
|
||||
>
|
||||
<rect width="1024" height="1024" fill="#030202" />
|
||||
<rect
|
||||
v-for="(r, i) in rects"
|
||||
:key="i"
|
||||
:x="r.x"
|
||||
:y="r.y"
|
||||
:width="r.w"
|
||||
:height="r.h"
|
||||
fill="white"
|
||||
class="logo-square"
|
||||
:style="{ '--delay': delays[i] + 'ms' }"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(defineProps<{
|
||||
size?: 'sm' | 'lg' | 'xl'
|
||||
noBorder?: boolean
|
||||
/** When true, fit to container (w-full h-full) instead of fixed size - for use inside logo-gradient-border */
|
||||
fit?: boolean
|
||||
}>(), { size: 'sm', noBorder: false, fit: false })
|
||||
|
||||
const sizeClass = props.fit
|
||||
? 'w-full h-full max-w-full max-h-full'
|
||||
: props.size === 'xl'
|
||||
? 'w-48 h-48 sm:w-64 sm:h-64 md:w-80 md:h-80'
|
||||
: props.size === 'lg'
|
||||
? 'w-32 h-32 sm:w-48 sm:h-48'
|
||||
: 'w-14 h-14'
|
||||
|
||||
// Parsed from favico-black.svg path - 20 rects
|
||||
const rects = [
|
||||
{ x: 357.614, y: 318, w: 71.007, h: 70.936 },
|
||||
{ x: 436.152, y: 318, w: 72.082, h: 70.936 },
|
||||
{ x: 515.766, y: 318, w: 72.082, h: 70.936 },
|
||||
{ x: 595.379, y: 318, w: 71.007, h: 70.936 },
|
||||
{ x: 595.379, y: 396.46, w: 71.007, h: 72.011 },
|
||||
{ x: 673.917, y: 396.46, w: 72.083, h: 72.011 },
|
||||
{ x: 278, y: 475.994, w: 72.083, h: 72.012 },
|
||||
{ x: 357.614, y: 475.994, w: 71.007, h: 72.012 },
|
||||
{ x: 436.152, y: 475.994, w: 72.082, h: 72.012 },
|
||||
{ x: 515.766, y: 475.994, w: 72.082, h: 72.012 },
|
||||
{ x: 595.379, y: 475.994, w: 71.007, h: 72.012 },
|
||||
{ x: 673.917, y: 475.994, w: 72.083, h: 72.012 },
|
||||
{ x: 278, y: 555.529, w: 72.083, h: 70.936 },
|
||||
{ x: 357.614, y: 555.529, w: 71.007, h: 70.936 },
|
||||
{ x: 595.379, y: 555.529, w: 71.007, h: 70.936 },
|
||||
{ x: 673.917, y: 555.529, w: 72.083, h: 70.936 },
|
||||
{ x: 357.614, y: 633.989, w: 71.007, h: 72.011 },
|
||||
{ x: 436.152, y: 633.989, w: 72.082, h: 72.011 },
|
||||
{ x: 515.766, y: 633.989, w: 72.082, h: 72.011 },
|
||||
{ x: 595.379, y: 633.989, w: 71.007, h: 72.011 },
|
||||
]
|
||||
|
||||
// Stagger delays (ms) - row-by-row top-to-bottom, left-to-right for a clean reveal
|
||||
const delays = [0, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400, 1500, 1600, 1700, 1800, 1900]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.logo-svg {
|
||||
will-change: auto;
|
||||
}
|
||||
|
||||
.logo-square {
|
||||
opacity: 0;
|
||||
animation: logo-square-in 3s ease-out infinite;
|
||||
animation-delay: var(--delay, 0ms);
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
/* Fade in only - no scale/position change. Squares stay fixed. */
|
||||
@keyframes logo-square-in {
|
||||
0% { opacity: 0; }
|
||||
15% { opacity: 1; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
619
neode-ui/src/components/AppLauncherOverlay.vue
Normal file
619
neode-ui/src/components/AppLauncherOverlay.vue
Normal file
@@ -0,0 +1,619 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="app-launcher">
|
||||
<div
|
||||
v-if="store.isOpen"
|
||||
class="fixed inset-0 z-[2400] flex items-center justify-center p-0 md:p-10"
|
||||
@click.self="store.close()"
|
||||
>
|
||||
<!-- Backdrop - blur like spotlight -->
|
||||
<div class="app-launcher-backdrop absolute inset-0 bg-black/60 backdrop-blur-md"></div>
|
||||
|
||||
<!-- Panel - inset with margins, glass style like spotlight -->
|
||||
<div
|
||||
class="app-launcher-panel relative z-10 flex flex-col overflow-hidden rounded-none md:rounded-2xl shadow-2xl"
|
||||
:class="panelClasses"
|
||||
>
|
||||
<!-- Header bar - sticky on mobile -->
|
||||
<div class="sticky top-0 z-10 flex items-center gap-3 border-b border-white/10 px-4 py-3 bg-black/60 backdrop-blur-md md:bg-transparent md:backdrop-blur-none">
|
||||
<div class="hidden md:flex items-center justify-center w-8 h-8 shrink-0 rounded cursor-grab hover:bg-white/10 transition-colors">
|
||||
<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>
|
||||
<span class="flex-1 truncate text-sm font-medium text-white/90">{{ store.title || 'App' }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center w-9 h-9 rounded-lg hover:bg-white/15 text-white/70 hover:text-white transition-colors disabled:opacity-70"
|
||||
aria-label="Refresh"
|
||||
:disabled="isRefreshing"
|
||||
@click="refreshIframe"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 transition-transform duration-300"
|
||||
:class="{ 'animate-spin': isRefreshing }"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center w-9 h-9 rounded-lg hover:bg-white/15 text-white/70 hover:text-white transition-colors"
|
||||
aria-label="Open in new tab"
|
||||
title="Open in new tab"
|
||||
@click="openInNewTab"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
ref="closeBtnRef"
|
||||
type="button"
|
||||
class="flex items-center justify-center w-9 h-9 rounded-lg hover:bg-white/15 text-white/70 hover:text-white transition-colors"
|
||||
aria-label="Close"
|
||||
@click="store.close()"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<kbd class="hidden sm:inline-flex px-2 py-1 text-xs text-white/50 bg-white/10 rounded">Esc</kbd>
|
||||
</div>
|
||||
|
||||
<!-- Iframe container - overflow hidden to clip inner scrollbars -->
|
||||
<div class="relative flex-1 min-h-0 bg-black/40 overflow-hidden">
|
||||
<!-- Loading indicator -->
|
||||
<Transition name="content-fade">
|
||||
<div v-if="iframeLoading" class="absolute inset-0 z-10 flex items-center justify-center bg-black/40">
|
||||
<svg class="animate-spin h-8 w-8 text-blue-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</Transition>
|
||||
<iframe
|
||||
ref="iframeRef"
|
||||
v-if="store.url && !iframeBlocked"
|
||||
:key="iframeRefreshKey"
|
||||
:src="store.url"
|
||||
class="absolute inset-0 w-full h-full border-0 iframe-scrollbar-hide"
|
||||
title="App content"
|
||||
@load="onIframeLoad"
|
||||
@error="onIframeError"
|
||||
/>
|
||||
|
||||
<!-- Iframe blocked fallback -->
|
||||
<Transition name="content-fade">
|
||||
<div v-if="iframeBlocked && !iframeLoading" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
|
||||
<div class="text-center px-8">
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 8V6a2 2 0 012-2h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2v-2m0-8h18M3 8v8m18-8v8" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-white mb-2">Can't display in frame</h3>
|
||||
<p class="text-white/50 text-sm mb-6">This app doesn't support embedded viewing.<br>Please open it in a new tab instead.</p>
|
||||
<button
|
||||
@click="openInNewTabAndClose"
|
||||
class="glass-button px-6 py-3 rounded-lg text-sm font-semibold inline-flex items-center gap-2"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
Open in new tab
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Payment Confirmation Dialog -->
|
||||
<Transition name="content-fade">
|
||||
<div v-if="pendingPayment" class="absolute inset-0 z-20 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
||||
<div class="bg-black/80 border border-white/15 rounded-2xl p-6 w-full max-w-sm mx-4 shadow-2xl">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-10 h-10 rounded-lg bg-orange-500/20 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-white font-semibold text-sm">Payment Request</h3>
|
||||
<p class="text-white/50 text-xs">{{ store.title || 'App' }} wants to make a payment</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 mb-4">
|
||||
<div class="flex justify-between items-center p-3 bg-white/5 rounded-lg">
|
||||
<span class="text-white/60 text-sm">Amount</span>
|
||||
<span class="text-orange-400 font-bold text-lg">{{ pendingPayment.amount_sats.toLocaleString() }} sats</span>
|
||||
</div>
|
||||
<div v-if="pendingPayment.memo" class="flex justify-between items-center p-3 bg-white/5 rounded-lg">
|
||||
<span class="text-white/60 text-sm">Memo</span>
|
||||
<span class="text-white/80 text-sm truncate ml-2">{{ pendingPayment.memo }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center p-3 bg-white/5 rounded-lg">
|
||||
<span class="text-white/60 text-sm">Method</span>
|
||||
<span class="text-white/80 text-sm capitalize">{{ pendingPayment.method || 'auto' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="paymentError" class="mb-3 p-2 bg-red-500/15 border border-red-500/20 rounded-lg">
|
||||
<p class="text-red-400 text-xs">{{ paymentError }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button @click="rejectPayment" class="flex-1 px-4 py-2.5 bg-white/5 border border-white/10 rounded-lg text-sm text-white/70 hover:bg-white/10 transition-colors">
|
||||
Deny
|
||||
</button>
|
||||
<button @click="approvePayment" :disabled="paymentProcessing" class="flex-1 px-4 py-2.5 bg-orange-500/20 border border-orange-500/30 rounded-lg text-sm font-medium text-orange-300 hover:bg-orange-500/30 transition-colors disabled:opacity-50">
|
||||
{{ paymentProcessing ? 'Paying...' : 'Approve' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- Nostr signing consent modal -->
|
||||
<NostrSignConsent
|
||||
:show="store.showConsent"
|
||||
:app-name="store.consentRequest?.appName ?? ''"
|
||||
:method="store.consentRequest?.method ?? ''"
|
||||
:event-kind="store.consentRequest?.eventKind"
|
||||
:content="store.consentRequest?.content"
|
||||
@approve="store.approveConsent"
|
||||
@deny="store.denyConsent"
|
||||
/>
|
||||
|
||||
<!-- Nostr identity picker (first-launch for identity-aware apps) -->
|
||||
<NostrIdentityPicker
|
||||
:show="showIdentityPicker"
|
||||
:app-name="store.title || 'App'"
|
||||
@select="onIdentitySelected"
|
||||
@cancel="showIdentityPicker = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import NostrSignConsent from '@/components/NostrSignConsent.vue'
|
||||
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
interface PaymentRequest {
|
||||
request_id: string
|
||||
amount_sats: number
|
||||
memo?: string
|
||||
method?: 'lightning' | 'ecash' | 'onchain' | 'auto'
|
||||
invoice?: string
|
||||
address?: string
|
||||
}
|
||||
|
||||
const store = useAppLauncherStore()
|
||||
const closeBtnRef = ref<HTMLButtonElement | null>(null)
|
||||
const iframeRef = ref<HTMLIFrameElement | null>(null)
|
||||
const iframeRefreshKey = ref(0)
|
||||
const isRefreshing = ref(false)
|
||||
const iframeLoading = ref(true)
|
||||
const iframeBlocked = ref(false)
|
||||
|
||||
// Nostr identity picker state
|
||||
const showIdentityPicker = ref(false)
|
||||
const IDENTITY_STORAGE_KEY = 'archipelago_app_identity_'
|
||||
|
||||
interface SelectedIdentity {
|
||||
id: string
|
||||
name: string
|
||||
did: string
|
||||
pubkey: string
|
||||
nostr_pubkey?: string
|
||||
nostr_npub?: string
|
||||
}
|
||||
|
||||
/** Get the stored identity for an app, or null if first launch */
|
||||
function getStoredIdentity(appUrl: string): SelectedIdentity | null {
|
||||
try {
|
||||
const key = IDENTITY_STORAGE_KEY + appUrl.replace(/[^a-z0-9]/gi, '_')
|
||||
const stored = localStorage.getItem(key)
|
||||
return stored ? JSON.parse(stored) as SelectedIdentity : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Store the selected identity for an app */
|
||||
function storeIdentity(appUrl: string, identity: SelectedIdentity) {
|
||||
try {
|
||||
const key = IDENTITY_STORAGE_KEY + appUrl.replace(/[^a-z0-9]/gi, '_')
|
||||
localStorage.setItem(key, JSON.stringify(identity))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** Handle identity selection from the picker */
|
||||
function onIdentitySelected(identity: SelectedIdentity) {
|
||||
showIdentityPicker.value = false
|
||||
if (store.url) {
|
||||
storeIdentity(store.url, identity)
|
||||
}
|
||||
// Send identity to the iframe
|
||||
sendSelectedIdentity(identity)
|
||||
}
|
||||
|
||||
/** Send a specific identity to the iframe */
|
||||
async function sendSelectedIdentity(identity: SelectedIdentity) {
|
||||
try {
|
||||
const challenge = `archipelago-identity:${Date.now()}`
|
||||
const sigRes = await rpcClient.call<{ signature: string }>({
|
||||
method: 'identity.sign',
|
||||
params: { id: identity.id, message: challenge }
|
||||
})
|
||||
const iframe = iframeRef.value
|
||||
if (!iframe?.contentWindow) return
|
||||
iframe.contentWindow.postMessage({
|
||||
type: 'archipelago:identity',
|
||||
did: identity.did,
|
||||
name: identity.name,
|
||||
pubkey: identity.pubkey,
|
||||
nostr_pubkey: identity.nostr_pubkey || null,
|
||||
nostr_npub: identity.nostr_npub || null,
|
||||
challenge,
|
||||
signature: sigRes.signature
|
||||
}, '*')
|
||||
} catch {
|
||||
/* identity signing not available */
|
||||
}
|
||||
}
|
||||
|
||||
// Timers for iframe load detection
|
||||
let loadTimeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
let contentCheckId: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function clearTimers() {
|
||||
if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null }
|
||||
if (contentCheckId) { clearTimeout(contentCheckId); contentCheckId = null }
|
||||
}
|
||||
|
||||
// Wallet connect — payment request state
|
||||
const pendingPayment = ref<PaymentRequest | null>(null)
|
||||
const paymentProcessing = ref(false)
|
||||
const paymentError = ref('')
|
||||
const paymentOrigin = ref('')
|
||||
|
||||
function refreshIframe() {
|
||||
isRefreshing.value = true
|
||||
iframeLoading.value = true
|
||||
iframeBlocked.value = false
|
||||
clearTimers()
|
||||
iframeRefreshKey.value++
|
||||
loadTimeoutId = setTimeout(() => {
|
||||
if (iframeLoading.value) {
|
||||
iframeLoading.value = false
|
||||
iframeBlocked.value = true
|
||||
}
|
||||
}, 15000)
|
||||
}
|
||||
|
||||
function openInNewTab() {
|
||||
if (store.url) {
|
||||
window.open(store.url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
|
||||
function openInNewTabAndClose() {
|
||||
openInNewTab()
|
||||
store.close()
|
||||
}
|
||||
|
||||
function onIframeLoad() {
|
||||
injectScrollbarHideIfSameOrigin()
|
||||
isRefreshing.value = false
|
||||
iframeLoading.value = false
|
||||
sendIdentityIfSupported()
|
||||
|
||||
// Clear the load timeout
|
||||
if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null }
|
||||
|
||||
// Check iframe content after a brief delay to let the app render
|
||||
contentCheckId = setTimeout(checkIframeContent, 2000)
|
||||
}
|
||||
|
||||
function onIframeError() {
|
||||
clearTimers()
|
||||
iframeLoading.value = false
|
||||
iframeBlocked.value = true
|
||||
}
|
||||
|
||||
/** Check if the iframe loaded meaningful content (same-origin only) */
|
||||
function checkIframeContent() {
|
||||
try {
|
||||
const iframe = iframeRef.value
|
||||
if (!iframe) return
|
||||
const doc = iframe.contentDocument
|
||||
if (!doc) return // Cross-origin — can't check, assume OK
|
||||
const body = doc.body
|
||||
if (!body || (body.children.length === 0 && body.innerText.trim() === '')) {
|
||||
iframeBlocked.value = true
|
||||
}
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('Cross-origin: can\'t access iframe, assume working', e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Apps that support the Archipelago identity protocol (postMessage) */
|
||||
function isIdentityAwareApp(url: string): boolean {
|
||||
return url.includes('indeehub') || url.includes('indeedhub')
|
||||
}
|
||||
|
||||
/** Send the user's identity to the iframe via postMessage.
|
||||
* On first launch, shows the identity picker modal.
|
||||
* On subsequent launches, uses the previously selected identity. */
|
||||
async function sendIdentityIfSupported() {
|
||||
if (!store.url || !isIdentityAwareApp(store.url)) return
|
||||
|
||||
// Check if we have a stored identity for this app
|
||||
const stored = getStoredIdentity(store.url)
|
||||
if (stored) {
|
||||
// Use the previously selected identity
|
||||
await sendSelectedIdentity(stored)
|
||||
return
|
||||
}
|
||||
|
||||
// First launch — show the identity picker
|
||||
showIdentityPicker.value = true
|
||||
return // Identity will be sent after selection via onIdentitySelected
|
||||
|
||||
}
|
||||
|
||||
function injectScrollbarHideIfSameOrigin() {
|
||||
try {
|
||||
const doc = iframeRef.value?.contentDocument
|
||||
if (!doc) return
|
||||
const style = doc.createElement('style')
|
||||
style.textContent = `
|
||||
* { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
*::-webkit-scrollbar { display: none; }
|
||||
`
|
||||
doc.head.appendChild(style)
|
||||
// Escape from inside iframe → close overlay and return focus to launcher
|
||||
doc.addEventListener('keydown', (e) => {
|
||||
if ((e as KeyboardEvent).key === 'Escape') {
|
||||
e.preventDefault()
|
||||
window.parent.postMessage({ type: 'app-launcher-escape' }, '*')
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
/* Cross-origin: cannot access iframe document */
|
||||
}
|
||||
}
|
||||
|
||||
const panelClasses = [
|
||||
'glass-card',
|
||||
'w-full h-full',
|
||||
'md:max-w-[calc(100vw-5rem)] md:max-h-[calc(100vh-5rem)]',
|
||||
]
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && store.isOpen) {
|
||||
store.close()
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
}
|
||||
|
||||
function onMessage(e: MessageEvent) {
|
||||
if (e.data?.type === 'app-launcher-escape' && store.isOpen) {
|
||||
store.close()
|
||||
}
|
||||
// Iframe app requests identity on demand
|
||||
if (e.data?.type === 'archipelago:identity:request' && store.isOpen) {
|
||||
sendIdentityIfSupported()
|
||||
}
|
||||
// Wallet connect — app requests a payment
|
||||
if (e.data?.type === 'archipelago:payment-request' && store.isOpen) {
|
||||
handlePaymentRequest(e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle incoming payment request from iframe app */
|
||||
function handlePaymentRequest(e: MessageEvent) {
|
||||
const data = e.data
|
||||
if (!data.amount_sats || typeof data.amount_sats !== 'number' || data.amount_sats <= 0) {
|
||||
sendPaymentResponse(e.origin, data.request_id, false, 'Invalid amount')
|
||||
return
|
||||
}
|
||||
pendingPayment.value = {
|
||||
request_id: data.request_id || `pay-${Date.now()}`,
|
||||
amount_sats: data.amount_sats,
|
||||
memo: data.memo,
|
||||
method: data.method || 'auto',
|
||||
invoice: data.invoice,
|
||||
address: data.address,
|
||||
}
|
||||
paymentOrigin.value = e.origin
|
||||
paymentError.value = ''
|
||||
paymentProcessing.value = false
|
||||
}
|
||||
|
||||
/** Send payment response back to the iframe */
|
||||
function sendPaymentResponse(origin: string, requestId: string, success: boolean, error?: string, receipt?: Record<string, unknown>) {
|
||||
const iframe = iframeRef.value
|
||||
if (!iframe?.contentWindow) return
|
||||
iframe.contentWindow.postMessage({
|
||||
type: 'archipelago:payment-response',
|
||||
request_id: requestId,
|
||||
success,
|
||||
error: error || null,
|
||||
receipt: receipt || null,
|
||||
}, origin || '*')
|
||||
}
|
||||
|
||||
/** User approves the payment */
|
||||
async function approvePayment() {
|
||||
if (!pendingPayment.value || paymentProcessing.value) return
|
||||
paymentProcessing.value = true
|
||||
paymentError.value = ''
|
||||
|
||||
const pay = pendingPayment.value
|
||||
const method = resolvePaymentMethod(pay)
|
||||
|
||||
try {
|
||||
let receipt: Record<string, unknown> = {}
|
||||
|
||||
if (method === 'ecash') {
|
||||
const res = await rpcClient.call<{ token: string; amount_sats: number }>({
|
||||
method: 'wallet.ecash-send',
|
||||
params: { amount_sats: pay.amount_sats },
|
||||
})
|
||||
receipt = { method: 'ecash', token: res.token, amount_sats: res.amount_sats }
|
||||
} else if (method === 'lightning') {
|
||||
if (pay.invoice) {
|
||||
const res = await rpcClient.call<{ payment_hash: string; amount_sats: number }>({
|
||||
method: 'lnd.payinvoice',
|
||||
params: { payment_request: pay.invoice },
|
||||
})
|
||||
receipt = { method: 'lightning', payment_hash: res.payment_hash, amount_sats: res.amount_sats }
|
||||
} else {
|
||||
// Create and immediately return an invoice for the requester to display
|
||||
const res = await rpcClient.call<{ payment_request: string }>({
|
||||
method: 'lnd.createinvoice',
|
||||
params: { amount_sats: pay.amount_sats, memo: pay.memo || '' },
|
||||
})
|
||||
receipt = { method: 'lightning', payment_request: res.payment_request, amount_sats: pay.amount_sats }
|
||||
}
|
||||
} else {
|
||||
if (!pay.address) {
|
||||
paymentError.value = 'No Bitcoin address provided for on-chain payment'
|
||||
return
|
||||
}
|
||||
const res = await rpcClient.call<{ txid: string }>({
|
||||
method: 'lnd.sendcoins',
|
||||
params: { addr: pay.address, amount: pay.amount_sats },
|
||||
})
|
||||
receipt = { method: 'onchain', txid: res.txid, amount_sats: pay.amount_sats }
|
||||
}
|
||||
|
||||
sendPaymentResponse(paymentOrigin.value, pay.request_id, true, undefined, receipt)
|
||||
pendingPayment.value = null
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Payment failed'
|
||||
paymentError.value = msg
|
||||
} finally {
|
||||
paymentProcessing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** User rejects the payment */
|
||||
function rejectPayment() {
|
||||
if (pendingPayment.value) {
|
||||
sendPaymentResponse(paymentOrigin.value, pendingPayment.value.request_id, false, 'Payment denied by user')
|
||||
pendingPayment.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve auto method based on amount */
|
||||
function resolvePaymentMethod(pay: PaymentRequest): 'ecash' | 'lightning' | 'onchain' {
|
||||
if (pay.method && pay.method !== 'auto') return pay.method
|
||||
if (pay.invoice) return 'lightning'
|
||||
if (pay.address) return 'onchain'
|
||||
if (pay.amount_sats < 1000) return 'ecash'
|
||||
if (pay.amount_sats > 500000) return 'onchain'
|
||||
return 'lightning'
|
||||
}
|
||||
|
||||
watch(
|
||||
() => store.isOpen,
|
||||
(open) => {
|
||||
if (open) {
|
||||
iframeLoading.value = true
|
||||
iframeBlocked.value = false
|
||||
clearTimers()
|
||||
// Set max load timeout — if iframe never fires load, show fallback
|
||||
loadTimeoutId = setTimeout(() => {
|
||||
if (iframeLoading.value) {
|
||||
iframeLoading.value = false
|
||||
iframeBlocked.value = true
|
||||
}
|
||||
}, 15000)
|
||||
closeBtnRef.value?.focus()
|
||||
} else {
|
||||
isRefreshing.value = false
|
||||
iframeLoading.value = true
|
||||
iframeBlocked.value = false
|
||||
clearTimers()
|
||||
// Clear any pending payment when closing
|
||||
if (pendingPayment.value) {
|
||||
rejectPayment()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', onKeyDown, true)
|
||||
window.addEventListener('message', onMessage)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearTimers()
|
||||
window.removeEventListener('keydown', onKeyDown, true)
|
||||
window.removeEventListener('message', onMessage)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-launcher-panel {
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.app-launcher-enter-active,
|
||||
.app-launcher-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.app-launcher-enter-active .app-launcher-backdrop {
|
||||
transition: opacity 0.3s ease, backdrop-filter 0.3s ease;
|
||||
}
|
||||
.app-launcher-leave-active .app-launcher-backdrop {
|
||||
transition: opacity 0.2s ease, backdrop-filter 0.2s ease;
|
||||
}
|
||||
|
||||
.app-launcher-enter-active .app-launcher-panel {
|
||||
transition: transform 0.35s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.3s ease;
|
||||
}
|
||||
.app-launcher-leave-active .app-launcher-panel {
|
||||
transition: transform 0.25s cubic-bezier(0.55, 0, 1, 0.45), opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.app-launcher-enter-from {
|
||||
opacity: 0;
|
||||
}
|
||||
.app-launcher-enter-from .app-launcher-backdrop {
|
||||
opacity: 0;
|
||||
backdrop-filter: blur(0);
|
||||
}
|
||||
.app-launcher-enter-from .app-launcher-panel {
|
||||
transform: translateY(40px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.app-launcher-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
.app-launcher-leave-to .app-launcher-backdrop {
|
||||
opacity: 0;
|
||||
backdrop-filter: blur(0);
|
||||
}
|
||||
.app-launcher-leave-to .app-launcher-panel {
|
||||
transform: translateY(30px);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
125
neode-ui/src/components/AppSwitcher.vue
Normal file
125
neode-ui/src/components/AppSwitcher.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<div class="relative" ref="containerRef">
|
||||
<!-- Mobile/tablet: Online button launches CLI directly (no CLI label, no dropdown) -->
|
||||
<button
|
||||
type="button"
|
||||
class="lg:hidden flex items-center gap-2 px-3 py-2 rounded-lg glass-card text-white/90 hover:bg-white/10 hover:text-white transition-colors min-w-0 border border-white/10"
|
||||
@click="selectCLI"
|
||||
>
|
||||
<img
|
||||
src="/assets/img/logo-archipelago.svg"
|
||||
alt="Archipelago"
|
||||
class="w-5 h-5 shrink-0 object-contain opacity-90"
|
||||
/>
|
||||
<div class="flex items-center gap-1.5 shrink-0">
|
||||
<div class="relative">
|
||||
<div class="w-2 h-2 rounded-full bg-green-400"></div>
|
||||
<div class="absolute inset-0 w-2 h-2 rounded-full bg-green-400 animate-ping opacity-50"></div>
|
||||
</div>
|
||||
<span class="text-xs text-white/80">Online</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Desktop: Full switcher with dropdown (lg and up) -->
|
||||
<button
|
||||
type="button"
|
||||
class="hidden lg:flex items-center gap-2 px-3 py-2 rounded-lg glass-card text-white/90 hover:bg-white/10 hover:text-white transition-colors min-w-0 border border-white/10"
|
||||
@click="showDropdown = !showDropdown"
|
||||
>
|
||||
<img
|
||||
src="/assets/img/logo-archipelago.svg"
|
||||
alt="Archipelago"
|
||||
class="w-5 h-5 shrink-0 object-contain opacity-90"
|
||||
/>
|
||||
<span class="text-sm font-medium truncate max-w-[100px] sm:max-w-[120px]">Archipelago CLI</span>
|
||||
<div class="flex items-center gap-1.5 shrink-0 pl-1 border-l border-white/20">
|
||||
<div class="relative">
|
||||
<div class="w-2 h-2 rounded-full bg-green-400"></div>
|
||||
<div class="absolute inset-0 w-2 h-2 rounded-full bg-green-400 animate-ping opacity-50"></div>
|
||||
</div>
|
||||
<span class="text-xs text-white/80">Online</span>
|
||||
</div>
|
||||
<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>
|
||||
298
neode-ui/src/components/CLIPopup.vue
Normal file
298
neode-ui/src/components/CLIPopup.vue
Normal file
@@ -0,0 +1,298 @@
|
||||
<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>
|
||||
<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]">F</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'
|
||||
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 (e) {
|
||||
if (import.meta.env.DEV) console.warn('Failed to load saved CLI position', e)
|
||||
savedPosition.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function savePosition(x: number, y: number) {
|
||||
savedPosition.value = { x, y }
|
||||
try {
|
||||
localStorage.setItem(SAVED_POSITION_KEY, JSON.stringify({ x, y }))
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('Failed to save CLI position', e)
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
116
neode-ui/src/components/ContainerStatus.vue
Normal file
116
neode-ui/src/components/ContainerStatus.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Status Indicator -->
|
||||
<div class="relative">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full transition-colors"
|
||||
:class="statusClass"
|
||||
></div>
|
||||
<div
|
||||
v-if="isRunning"
|
||||
class="absolute inset-0 w-3 h-3 rounded-full animate-ping opacity-75"
|
||||
:class="statusClass"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Status Text -->
|
||||
<span class="text-sm font-medium" :class="textClass">
|
||||
{{ statusText }}
|
||||
</span>
|
||||
|
||||
<!-- Health Badge (if running) -->
|
||||
<span
|
||||
v-if="isRunning && health"
|
||||
class="px-2 py-0.5 rounded text-xs font-medium"
|
||||
:class="healthClass"
|
||||
>
|
||||
{{ healthText }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
state: 'created' | 'running' | 'stopped' | 'exited' | 'paused' | 'unknown'
|
||||
health?: 'healthy' | 'unhealthy' | 'unknown' | 'starting'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
health: 'unknown',
|
||||
})
|
||||
|
||||
const isRunning = computed(() => props.state === 'running')
|
||||
|
||||
const statusClass = computed(() => {
|
||||
switch (props.state) {
|
||||
case 'running':
|
||||
return 'bg-green-400'
|
||||
case 'stopped':
|
||||
case 'exited':
|
||||
return 'bg-gray-400'
|
||||
case 'paused':
|
||||
return 'bg-yellow-400'
|
||||
default:
|
||||
return 'bg-red-400'
|
||||
}
|
||||
})
|
||||
|
||||
const textClass = computed(() => {
|
||||
switch (props.state) {
|
||||
case 'running':
|
||||
return 'text-green-400'
|
||||
case 'stopped':
|
||||
case 'exited':
|
||||
return 'text-gray-400'
|
||||
case 'paused':
|
||||
return 'text-yellow-400'
|
||||
default:
|
||||
return 'text-red-400'
|
||||
}
|
||||
})
|
||||
|
||||
const statusText = computed(() => {
|
||||
switch (props.state) {
|
||||
case 'running':
|
||||
return 'Running'
|
||||
case 'stopped':
|
||||
return 'Stopped'
|
||||
case 'exited':
|
||||
return 'Exited'
|
||||
case 'paused':
|
||||
return 'Paused'
|
||||
case 'created':
|
||||
return 'Created'
|
||||
default:
|
||||
return 'Unknown'
|
||||
}
|
||||
})
|
||||
|
||||
const healthClass = computed(() => {
|
||||
switch (props.health) {
|
||||
case 'healthy':
|
||||
return 'bg-green-500/20 text-green-400 border border-green-500/30'
|
||||
case 'unhealthy':
|
||||
return 'bg-red-500/20 text-red-400 border border-red-500/30'
|
||||
case 'starting':
|
||||
return 'bg-yellow-500/20 text-yellow-400 border border-yellow-500/30'
|
||||
default:
|
||||
return 'bg-gray-500/20 text-gray-400 border border-gray-500/30'
|
||||
}
|
||||
})
|
||||
|
||||
const healthText = computed(() => {
|
||||
switch (props.health) {
|
||||
case 'healthy':
|
||||
return 'Healthy'
|
||||
case 'unhealthy':
|
||||
return 'Unhealthy'
|
||||
case 'starting':
|
||||
return 'Starting'
|
||||
default:
|
||||
return 'Unknown'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
21
neode-ui/src/components/ControllerIndicator.vue
Normal file
21
neode-ui/src/components/ControllerIndicator.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="store.isActive"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-lg bg-white/5 border border-white/10"
|
||||
title="Controller connected - use arrows & Enter to navigate"
|
||||
>
|
||||
<svg class="w-5 h-5 text-amber-400/90 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="4" y="8" width="16" height="10" rx="2" stroke-width="2" />
|
||||
<circle cx="9" cy="13" r="1.5" fill="currentColor" />
|
||||
<circle cx="15" cy="13" r="1.5" fill="currentColor" />
|
||||
<path stroke-linecap="round" stroke-width="2" d="M12 10v2M11 11h2" />
|
||||
</svg>
|
||||
<span class="text-xs text-white/70 hidden sm:inline">Controller</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useControllerStore } from '@/stores/controller'
|
||||
|
||||
const store = useControllerStore()
|
||||
</script>
|
||||
78
neode-ui/src/components/EasyHome.vue
Normal file
78
neode-ui/src/components/EasyHome.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 mb-8 transition-opacity duration-300"
|
||||
:class="{ 'opacity-0 pointer-events-none': !show }"
|
||||
>
|
||||
<RouterLink
|
||||
v-for="(goal, idx) in goals"
|
||||
:key="goal.id"
|
||||
:to="`/dashboard/goals/${goal.id}`"
|
||||
class="goal-card glass-card p-6 block"
|
||||
:class="{ 'home-card-animate': animate }"
|
||||
:style="{ '--card-stagger': idx }"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="w-10 h-10 rounded-xl bg-white/10 flex items-center justify-center shrink-0">
|
||||
<span class="text-xl">{{ goalIcon(goal.icon) }}</span>
|
||||
</div>
|
||||
<span class="goal-status-badge" :class="statusBadgeClass(goal.id)">
|
||||
<span v-if="goalStatuses[goal.id] === 'completed'" class="w-1.5 h-1.5 rounded-full bg-green-400"></span>
|
||||
<span v-else-if="goalStatuses[goal.id] === 'in-progress'" class="w-1.5 h-1.5 rounded-full bg-orange-400"></span>
|
||||
{{ statusLabel(goal.id) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-semibold text-white mb-1">{{ goal.title }}</h3>
|
||||
<p class="text-sm text-white/55 mb-4 leading-relaxed">{{ goal.subtitle }}</p>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-white/40">{{ goal.estimatedTime }}</span>
|
||||
<span class="text-xs text-white/50 flex items-center gap-1">
|
||||
{{ goal.difficulty === 'beginner' ? 'Beginner' : 'Intermediate' }}
|
||||
</span>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { GOALS } from '@/data/goals'
|
||||
import { useGoalStore } from '@/stores/goals'
|
||||
|
||||
defineProps<{
|
||||
show: boolean
|
||||
animate: boolean
|
||||
}>()
|
||||
|
||||
const goalStore = useGoalStore()
|
||||
const goals = GOALS
|
||||
const goalStatuses = goalStore.goalStatuses
|
||||
|
||||
function goalIcon(icon: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
shop: '🏪',
|
||||
payments: '⚡',
|
||||
photos: '📸',
|
||||
files: '📁',
|
||||
lightning: '⚡',
|
||||
identity: '🔑',
|
||||
backup: '💾',
|
||||
}
|
||||
return icons[icon] || '📦'
|
||||
}
|
||||
|
||||
function statusLabel(goalId: string): string {
|
||||
const status = goalStatuses[goalId]
|
||||
if (status === 'completed') return 'Done'
|
||||
if (status === 'in-progress') return 'In Progress'
|
||||
return 'Start'
|
||||
}
|
||||
|
||||
function statusBadgeClass(goalId: string): string {
|
||||
const status = goalStatuses[goalId]
|
||||
if (status === 'completed') return 'goal-status-badge-completed'
|
||||
if (status === 'in-progress') return 'goal-status-badge-in-progress'
|
||||
return 'goal-status-badge-not-started'
|
||||
}
|
||||
</script>
|
||||
29
neode-ui/src/components/EmptyState.vue
Normal file
29
neode-ui/src/components/EmptyState.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center py-12 px-4 text-center">
|
||||
<div class="w-16 h-16 rounded-2xl bg-white/5 flex items-center justify-center mb-4">
|
||||
<span class="text-3xl">{{ icon }}</span>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-white/80 mb-2">{{ title }}</h3>
|
||||
<p class="text-sm text-white/50 max-w-sm mb-6">{{ description }}</p>
|
||||
<button
|
||||
v-if="actionLabel"
|
||||
@click="$emit('action')"
|
||||
class="glass-button px-5 py-2.5 rounded-lg text-sm font-medium"
|
||||
>
|
||||
{{ actionLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
icon?: string
|
||||
title: string
|
||||
description: string
|
||||
actionLabel?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
action: []
|
||||
}>()
|
||||
</script>
|
||||
65
neode-ui/src/components/HelpGuideModal.vue
Normal file
65
neode-ui/src/components/HelpGuideModal.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-[3000] flex items-center justify-center p-4"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
||||
<div
|
||||
ref="modalRef"
|
||||
@click.stop
|
||||
class="glass-card p-6 max-w-lg w-full relative z-10 max-h-[80vh] overflow-y-auto"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4 mb-4">
|
||||
<h3 class="text-xl font-semibold text-white">{{ title }}</h3>
|
||||
<button
|
||||
@click="$emit('close')"
|
||||
class="p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-white/80 prose prose-invert max-w-none">
|
||||
<p class="whitespace-pre-wrap">{{ content }}</p>
|
||||
</div>
|
||||
<div v-if="relatedPath" class="mt-4">
|
||||
<router-link
|
||||
:to="relatedPath"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 glass-button rounded-lg text-sm font-medium"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
Go to related page
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
title: string
|
||||
content: string
|
||||
relatedPath?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const modalRef = ref<HTMLElement | null>(null)
|
||||
useModalKeyboard(modalRef, computed(() => props.show), () => emit('close'))
|
||||
</script>
|
||||
140
neode-ui/src/components/IdentityPicker.vue
Normal file
140
neode-ui/src/components/IdentityPicker.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<div class="relative" ref="pickerRef">
|
||||
<button
|
||||
@click="isOpen = !isOpen"
|
||||
class="w-full flex items-center gap-3 px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-sm text-white hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<!-- Selected Identity -->
|
||||
<div v-if="selectedIdentity" class="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div class="w-6 h-6 rounded-full flex items-center justify-center shrink-0" :class="purposeColor(selectedIdentity.purpose)">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="truncate">{{ selectedIdentity.name }}</span>
|
||||
<span class="text-white/40 text-xs font-mono truncate">{{ truncateDid(selectedIdentity.did) }}</span>
|
||||
</div>
|
||||
<div v-else class="flex-1 text-white/50">Select identity...</div>
|
||||
|
||||
<!-- Chevron -->
|
||||
<svg class="w-4 h-4 text-white/40 shrink-0 transition-transform" :class="{ 'rotate-180': isOpen }" 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>
|
||||
|
||||
<!-- Dropdown -->
|
||||
<Transition name="content-fade">
|
||||
<div v-if="isOpen" class="absolute left-0 right-0 mt-1 z-20 glass-card p-1 rounded-lg max-h-48 overflow-y-auto">
|
||||
<div v-if="loading" class="p-3 text-center text-white/50 text-sm">Loading...</div>
|
||||
<div v-else-if="identities.length === 0" class="p-3 text-center text-white/50 text-sm">No identities</div>
|
||||
<button
|
||||
v-for="id in identities"
|
||||
:key="id.id"
|
||||
@click="selectIdentity(id)"
|
||||
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-white/80 hover:bg-white/10 transition-colors"
|
||||
:class="{ 'bg-white/10': modelValue === id.id }"
|
||||
>
|
||||
<div class="w-6 h-6 rounded-full flex items-center justify-center shrink-0" :class="purposeColor(id.purpose)">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0 text-left">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="truncate">{{ id.name }}</span>
|
||||
<span v-if="id.is_default" class="text-yellow-400 text-xs">★</span>
|
||||
</div>
|
||||
<p class="text-white/40 text-xs font-mono truncate">{{ truncateDid(id.did) }}</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
interface Identity {
|
||||
id: string
|
||||
name: string
|
||||
purpose: string
|
||||
pubkey: string
|
||||
did: string
|
||||
is_default: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', id: string): void
|
||||
(e: 'select', identity: Identity): void
|
||||
}>()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const loading = ref(false)
|
||||
const identities = ref<Identity[]>([])
|
||||
const pickerRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const selectedIdentity = computed(() =>
|
||||
identities.value.find(i => i.id === props.modelValue)
|
||||
)
|
||||
|
||||
function purposeColor(purpose: string): string {
|
||||
switch (purpose) {
|
||||
case 'personal': return 'bg-blue-500/20 text-blue-400'
|
||||
case 'business': return 'bg-orange-500/20 text-orange-400'
|
||||
case 'anonymous': return 'bg-purple-500/20 text-purple-400'
|
||||
default: return 'bg-white/10 text-white/60'
|
||||
}
|
||||
}
|
||||
|
||||
function truncateDid(did: string): string {
|
||||
if (did.length <= 30) return did
|
||||
return did.slice(0, 18) + '...' + did.slice(-8)
|
||||
}
|
||||
|
||||
function selectIdentity(id: Identity) {
|
||||
emit('update:modelValue', id.id)
|
||||
emit('select', id)
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
if (pickerRef.value && !pickerRef.value.contains(e.target as Node)) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadIdentities() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await rpcClient.call<{ identities: Identity[] }>({ method: 'identity.list' })
|
||||
identities.value = res.identities || []
|
||||
// Auto-select default if no value set
|
||||
if (!props.modelValue) {
|
||||
const defaultId = identities.value.find(i => i.is_default)
|
||||
if (defaultId) {
|
||||
emit('update:modelValue', defaultId.id)
|
||||
emit('select', defaultId)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
identities.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadIdentities()
|
||||
document.addEventListener('click', onClickOutside)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onClickOutside)
|
||||
})
|
||||
</script>
|
||||
172
neode-ui/src/components/LineChart.vue
Normal file
172
neode-ui/src/components/LineChart.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
class="monitoring-chart"
|
||||
:width="width"
|
||||
:height="height"
|
||||
></canvas>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
export interface ChartDataset {
|
||||
label: string
|
||||
data: number[]
|
||||
color: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
datasets: ChartDataset[]
|
||||
labels?: string[]
|
||||
width?: number
|
||||
height?: number
|
||||
yMax?: number
|
||||
yLabel?: string
|
||||
showGrid?: boolean
|
||||
}>(),
|
||||
{
|
||||
width: 400,
|
||||
height: 180,
|
||||
showGrid: true,
|
||||
},
|
||||
)
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
function draw() {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
canvas.width = props.width * dpr
|
||||
canvas.height = props.height * dpr
|
||||
canvas.style.width = `${props.width}px`
|
||||
canvas.style.height = `${props.height}px`
|
||||
ctx.scale(dpr, dpr)
|
||||
|
||||
const w = props.width
|
||||
const h = props.height
|
||||
const pad = { top: 10, right: 12, bottom: 24, left: 44 }
|
||||
const plotW = w - pad.left - pad.right
|
||||
const plotH = h - pad.top - pad.bottom
|
||||
|
||||
// Clear
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
if (!props.datasets.length || !props.datasets[0]?.data.length) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.3)'
|
||||
ctx.font = '12px system-ui'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('No data yet', w / 2, h / 2)
|
||||
return
|
||||
}
|
||||
|
||||
// Compute y range
|
||||
let yMax = props.yMax ?? 0
|
||||
if (!yMax) {
|
||||
for (const ds of props.datasets) {
|
||||
for (const v of ds.data) {
|
||||
if (v > yMax) yMax = v
|
||||
}
|
||||
}
|
||||
yMax = yMax * 1.1 || 1
|
||||
}
|
||||
|
||||
const maxPoints = Math.max(...props.datasets.map((d) => d.data.length))
|
||||
|
||||
// Grid lines
|
||||
if (props.showGrid) {
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.06)'
|
||||
ctx.lineWidth = 1
|
||||
const gridCount = 4
|
||||
for (let i = 0; i <= gridCount; i++) {
|
||||
const y = pad.top + (plotH / gridCount) * i
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(pad.left, y)
|
||||
ctx.lineTo(pad.left + plotW, y)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// Y-axis labels
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.4)'
|
||||
ctx.font = '10px system-ui'
|
||||
ctx.textAlign = 'right'
|
||||
for (let i = 0; i <= gridCount; i++) {
|
||||
const y = pad.top + (plotH / gridCount) * i
|
||||
const val = yMax - (yMax / gridCount) * i
|
||||
ctx.fillText(formatValue(val), pad.left - 6, y + 3)
|
||||
}
|
||||
}
|
||||
|
||||
// Draw each dataset
|
||||
for (const ds of props.datasets) {
|
||||
if (!ds.data.length) continue
|
||||
|
||||
ctx.strokeStyle = ds.color
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.lineJoin = 'round'
|
||||
ctx.lineCap = 'round'
|
||||
|
||||
ctx.beginPath()
|
||||
for (let i = 0; i < ds.data.length; i++) {
|
||||
const x = pad.left + (i / Math.max(maxPoints - 1, 1)) * plotW
|
||||
const y = pad.top + plotH - (ds.data[i]! / yMax) * plotH
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y)
|
||||
} else {
|
||||
ctx.lineTo(x, y)
|
||||
}
|
||||
}
|
||||
ctx.stroke()
|
||||
|
||||
// Area fill
|
||||
ctx.globalAlpha = 0.08
|
||||
ctx.fillStyle = ds.color
|
||||
ctx.lineTo(pad.left + ((ds.data.length - 1) / Math.max(maxPoints - 1, 1)) * plotW, pad.top + plotH)
|
||||
ctx.lineTo(pad.left, pad.top + plotH)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
ctx.globalAlpha = 1.0
|
||||
}
|
||||
|
||||
// X-axis labels (first, middle, last)
|
||||
if (props.labels && props.labels.length > 0) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.4)'
|
||||
ctx.font = '10px system-ui'
|
||||
ctx.textAlign = 'center'
|
||||
const indices = [0, Math.floor(props.labels.length / 2), props.labels.length - 1]
|
||||
for (const idx of indices) {
|
||||
if (idx >= 0 && idx < props.labels.length) {
|
||||
const x = pad.left + (idx / Math.max(props.labels.length - 1, 1)) * plotW
|
||||
ctx.fillText(props.labels[idx]!, pad.left + plotW + pad.right > w ? x : x, h - 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatValue(val: number): string {
|
||||
if (val >= 1_000_000_000) return `${(val / 1_000_000_000).toFixed(1)}G`
|
||||
if (val >= 1_000_000) return `${(val / 1_000_000).toFixed(1)}M`
|
||||
if (val >= 1_000) return `${(val / 1_000).toFixed(1)}K`
|
||||
return val.toFixed(val < 10 ? 1 : 0)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.datasets, props.labels, props.width, props.height],
|
||||
() => draw(),
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
draw()
|
||||
window.addEventListener('resize', draw)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', draw)
|
||||
})
|
||||
</script>
|
||||
51
neode-ui/src/components/ModeSwitcher.vue
Normal file
51
neode-ui/src/components/ModeSwitcher.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<!-- Compact mode: small pill for chat fullscreen -->
|
||||
<div v-if="compact" class="chat-mode-pill-inner" @click="handleCompactClick">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
<span class="text-xs font-medium">{{ currentLabel }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Full mode switcher -->
|
||||
<div v-else class="mode-switcher mode-switcher-full">
|
||||
<button
|
||||
v-for="m in modes"
|
||||
:key="m.id"
|
||||
@click="uiMode.setMode(m.id)"
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': uiMode.mode === m.id }"
|
||||
>
|
||||
{{ m.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUIModeStore } from '@/stores/uiMode'
|
||||
import type { UIMode } from '@/types/api'
|
||||
|
||||
const props = defineProps<{
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const uiMode = useUIModeStore()
|
||||
const router = useRouter()
|
||||
|
||||
const modes: { id: UIMode; label: string }[] = [
|
||||
{ id: 'easy', label: 'Easy' },
|
||||
{ id: 'gamer', label: 'Pro' },
|
||||
]
|
||||
|
||||
const currentLabel = computed(() => {
|
||||
const found = modes.find(m => m.id === uiMode.mode)
|
||||
return found ? found.label : 'Pro'
|
||||
})
|
||||
|
||||
function handleCompactClick() {
|
||||
const newMode = uiMode.cycleMode()
|
||||
router.push(newMode === 'chat' ? '/dashboard/chat' : '/dashboard')
|
||||
}
|
||||
</script>
|
||||
322
neode-ui/src/components/NostrIdentityPicker.vue
Normal file
322
neode-ui/src/components/NostrIdentityPicker.vue
Normal file
@@ -0,0 +1,322 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="identity-picker">
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-[3100] flex items-center justify-center p-4"
|
||||
@click="$emit('cancel')"
|
||||
>
|
||||
<!-- Backdrop — frosted blur -->
|
||||
<div class="absolute inset-0 bg-black/40 backdrop-blur-2xl"></div>
|
||||
|
||||
<!-- Main panel -->
|
||||
<div
|
||||
ref="modalRef"
|
||||
@click.stop
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="`Select identity for ${appName}`"
|
||||
class="relative z-10 w-full max-w-lg"
|
||||
>
|
||||
<!-- Header: screensaver-style glass disc + radial viz ring -->
|
||||
<div class="relative mb-6 flex flex-col items-center">
|
||||
<div class="nostr-hero">
|
||||
<!-- Radial viz segments — exact screensaver pattern, 48 bars, #FAFAFA -->
|
||||
<div class="nostr-viz-ring">
|
||||
<div
|
||||
v-for="(_, i) in 48"
|
||||
:key="i"
|
||||
class="nostr-viz-segment"
|
||||
:style="{ '--seg-i': i, '--seg-deg': `${(i / 48) * 360}deg` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Glass disc — exact logo-gradient-border from screensaver -->
|
||||
<div class="nostr-glass-border">
|
||||
<div class="nostr-glass-inner">
|
||||
<svg viewBox="0 0 122.88 88.39" width="42" height="30" xmlns="http://www.w3.org/2000/svg" class="nostr-cinema-svg">
|
||||
<path fill="#FAFAFA" fill-rule="evenodd" clip-rule="evenodd" d="M87.51,21.16c5.26,1.45,10.79,1.84,16.58,1.18c1.42-0.16,2.81-0.35,4.16-0.53c6.46-0.84,11.86-1.32,13.78,3.52 c3.39,8.55-4.28,27.07-8.32,34.56c-8.32,15.43-24.9,32.69-44.08,27.57c-2.99-0.8-5.68-2.1-8.08-3.86 c6.3-3.51,11.28-8.9,15.13-15.24l-0.01,0.02c4.77,0.26,9.73,2.78,14.27,5.44c0.33-5.99-5.46-9.97-10.62-12.45 c4.14-9.29,6.33-19.72,7.01-29.03C87.53,29.46,87.64,25.53,87.51,21.16L87.51,21.16z M2.61,6.51c1.56-1.48,3.92-1.87,6.6-1.7 c5.03,0.31,10.23,1.86,15.11,3.18c10.61,2.86,20.99,1.93,31.1-2.74c1.36-0.63,2.69-1.28,3.98-1.9C65.56,0.37,70.8-1.9,74.31,2.3 c6.21,7.42,4.68,28.44,3.13,37.25c-3.2,18.15-14.03,40.87-34.88,42.1c-11.06,0.65-20.49-5.57-28.61-17.32 c-5.17-8-8.9-16.22-11.18-24.67C1.13,33.5-2.46,11.34,2.61,6.51L2.61,6.51z M12.94,34.3c-1.91-0.5-3.01-1.12-3.38-1.85 c-1.47-2.92,10.66-10.29,19.22-3.52C40.95,38.4,17.26,35.58,12.94,34.3L12.94,34.3z M32.63,62.79c-3.23-2.31-4.96-5.16-5.9-9.02 c10.67,5.4,20.66,5.01,29.96-2.42c-0.37,3.29-1.44,6.24-3.28,8.83C47.98,67.83,40.04,68.08,32.63,62.79L32.63,62.79z M67.07,30.06 c1.79-0.84,2.76-1.65,2.99-2.44c0.92-3.14-12.35-8.19-19.54,0.03C40.27,39.18,63.06,32.1,67.07,30.06L67.07,30.06z M90.82,42.07 c5.04-4.04,11.94-3.22,16.74,0.73c1.22,1.01,4.57,3.95,2.64,5.56c-0.53,0.44-1.41,0.69-2.63,0.75c-2.98,0.34-7.32-0.28-10.78-1.71 C94.07,46.3,92.01,44.83,90.82,42.07L90.82,42.07z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="mt-5 text-lg font-semibold text-white">Select Identity</h2>
|
||||
<p class="mt-1 text-white/25 tracking-widest uppercase" style="font-size: 10px;">Nostr authentication protocol</p>
|
||||
</div>
|
||||
|
||||
<!-- Identity list -->
|
||||
<div class="glass-card p-4 space-y-2 max-h-[50vh] overflow-y-auto" role="radiogroup" aria-label="Available identities">
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<svg class="animate-spin h-6 w-6 text-white/40" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
<span class="ml-3 text-white/60 text-sm">Loading identities...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="identities.length === 0" class="text-center py-8">
|
||||
<p class="text-white/50 text-sm">No identities found.</p>
|
||||
<p class="text-white/30 text-xs mt-1">Create one in Settings → Credentials</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-for="identity in identities"
|
||||
:key="identity.id"
|
||||
type="button"
|
||||
role="radio"
|
||||
:aria-checked="selectedId === identity.id"
|
||||
:aria-label="`Identity: ${identity.name}`"
|
||||
class="w-full text-left p-3 rounded-lg transition-all duration-200"
|
||||
:class="selectedId === identity.id
|
||||
? 'bg-white/10 ring-1 ring-white/20'
|
||||
: 'bg-white/[0.03] hover:bg-white/[0.06]'"
|
||||
@click="selectedId = identity.id"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
|
||||
:class="avatarClasses(identity.purpose)"
|
||||
>
|
||||
<span class="text-sm font-bold">{{ identity.name.charAt(0).toUpperCase() }}</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-white font-semibold text-sm truncate">{{ identity.name }}</span>
|
||||
<span v-if="identity.is_default" class="text-[10px] px-1.5 py-0.5 rounded bg-white/10 text-white/60">default</span>
|
||||
</div>
|
||||
<div class="mt-0.5">
|
||||
<span v-if="identity.nostr_npub" class="text-white/35 text-xs font-mono truncate">{{ truncateNpub(identity.nostr_npub) }}</span>
|
||||
<span v-else class="text-red-400/60 text-xs">No Nostr key</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<div v-if="selectedId === identity.id" class="w-5 h-5 rounded-full bg-white/15 flex items-center justify-center">
|
||||
<div class="w-2.5 h-2.5 rounded-full bg-white/70"></div>
|
||||
</div>
|
||||
<div v-else class="w-5 h-5 rounded-full bg-white/5"></div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3 mt-4">
|
||||
<button @click="$emit('cancel')" class="glass-button flex-1 py-3 rounded-lg text-sm font-medium text-white/70">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="confirm"
|
||||
:disabled="!selectedId || !hasNostrKey"
|
||||
class="flex-1 py-3 rounded-lg text-sm font-semibold transition-all duration-200 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
:class="selectedId && hasNostrKey
|
||||
? 'bg-white/10 text-white hover:bg-white/15'
|
||||
: 'bg-white/[0.03] text-white/40'"
|
||||
>
|
||||
Authenticate
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="mt-3 text-center text-[10px] text-white/20 tracking-widest">
|
||||
NIP-07 · SECP256K1 · Signed locally
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
interface Identity {
|
||||
id: string
|
||||
name: string
|
||||
purpose: string
|
||||
pubkey: string
|
||||
did: string
|
||||
is_default: boolean
|
||||
nostr_pubkey?: string
|
||||
nostr_npub?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
appName: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [identity: Identity]
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const modalRef = ref<HTMLElement | null>(null)
|
||||
const identities = ref<Identity[]>([])
|
||||
const selectedId = ref<string | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
useModalKeyboard(modalRef, computed(() => props.show), () => emit('cancel'))
|
||||
|
||||
const hasNostrKey = computed(() => {
|
||||
const selected = identities.value.find(i => i.id === selectedId.value)
|
||||
return selected?.nostr_pubkey != null
|
||||
})
|
||||
|
||||
watch(() => props.show, async (open) => {
|
||||
if (open) await loadIdentities()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (props.show) loadIdentities()
|
||||
})
|
||||
|
||||
async function loadIdentities() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await rpcClient.call<{ identities: Identity[] }>({ method: 'identity.list' })
|
||||
identities.value = res.identities || []
|
||||
const defaultId = identities.value.find(i => i.is_default && i.nostr_pubkey)
|
||||
|| identities.value.find(i => i.nostr_pubkey)
|
||||
if (defaultId) selectedId.value = defaultId.id
|
||||
} catch {
|
||||
identities.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
const selected = identities.value.find(i => i.id === selectedId.value)
|
||||
if (selected) emit('select', selected)
|
||||
}
|
||||
|
||||
function truncateNpub(npub: string): string {
|
||||
if (npub.length <= 20) return npub
|
||||
return npub.slice(0, 12) + '...' + npub.slice(-6)
|
||||
}
|
||||
|
||||
function avatarClasses(purpose: string): string {
|
||||
switch (purpose) {
|
||||
case 'business': return 'bg-blue-500/15 text-blue-400'
|
||||
case 'anonymous': return 'bg-purple-500/15 text-purple-400'
|
||||
default: return 'bg-white/10 text-white/80'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ── Hero container ── */
|
||||
.nostr-hero {
|
||||
position: relative;
|
||||
width: 148px;
|
||||
height: 148px;
|
||||
}
|
||||
|
||||
/* ── Radial viz ring — exact screensaver pattern, #FAFAFA ── */
|
||||
.nostr-viz-ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nostr-viz-segment {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 2.5px;
|
||||
height: 14px;
|
||||
margin-left: -1.25px;
|
||||
margin-top: -7px;
|
||||
background: linear-gradient(to bottom, rgba(250, 250, 250, 0.4), rgba(250, 250, 250, 0.06));
|
||||
border-radius: 1.5px;
|
||||
transform-origin: center center;
|
||||
transform: rotate(var(--seg-deg)) translateY(-60px);
|
||||
animation: seg-pulse 14s ease-in-out infinite;
|
||||
animation-delay: calc(var(--seg-i) * 0.02s);
|
||||
}
|
||||
|
||||
/* Exact screensaver keyframes — 5 normal pulses then 1 strong expression, 14s total */
|
||||
@keyframes seg-pulse {
|
||||
0% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
|
||||
7.1% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1); }
|
||||
14.3% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
|
||||
21.4% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1); }
|
||||
28.6% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
|
||||
35.7% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1); }
|
||||
42.9% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
|
||||
50% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1); }
|
||||
57.1% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
|
||||
64.3% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1); }
|
||||
71.4% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
|
||||
78.6% { opacity: 1; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1.5); }
|
||||
85.7% { opacity: 1; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1.5); }
|
||||
92.9% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
|
||||
100% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
|
||||
}
|
||||
|
||||
/* ── Glass disc — exact screensaver logo-gradient-border ── */
|
||||
.nostr-glass-border {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 104px;
|
||||
height: 104px;
|
||||
border-radius: 9999px;
|
||||
padding: 3px;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.6) 0%, rgba(0, 0, 0, 0.8) 100%);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
filter: drop-shadow(0 0 24px rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
.nostr-glass-inner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 9999px;
|
||||
background: #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ── Cinema icon — breathing glow ── */
|
||||
.nostr-cinema-svg {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
filter: drop-shadow(0 0 12px rgba(250, 250, 250, 0.12));
|
||||
animation: cinema-breathe 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes cinema-breathe {
|
||||
0%, 100% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1);
|
||||
filter: drop-shadow(0 0 8px rgba(250, 250, 250, 0.08));
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.08);
|
||||
filter: drop-shadow(0 0 20px rgba(250, 250, 250, 0.22));
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Modal transitions ── */
|
||||
.identity-picker-enter-active,
|
||||
.identity-picker-leave-active {
|
||||
transition: opacity 0.4s ease;
|
||||
}
|
||||
.identity-picker-enter-active > .relative {
|
||||
transition: transform 0.5s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.4s ease;
|
||||
}
|
||||
.identity-picker-leave-active > .relative {
|
||||
transition: transform 0.25s ease, opacity 0.2s ease;
|
||||
}
|
||||
.identity-picker-enter-from { opacity: 0; }
|
||||
.identity-picker-enter-from > .relative { transform: translateY(24px) scale(0.94); opacity: 0; }
|
||||
.identity-picker-leave-to { opacity: 0; }
|
||||
.identity-picker-leave-to > .relative { transform: translateY(10px) scale(0.98); opacity: 0; }
|
||||
</style>
|
||||
152
neode-ui/src/components/NostrSignConsent.vue
Normal file
152
neode-ui/src/components/NostrSignConsent.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-[3000] flex items-center justify-center p-4"
|
||||
@click="deny"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
||||
<div
|
||||
ref="modalRef"
|
||||
@click.stop
|
||||
class="glass-card p-6 max-w-md w-full relative z-10"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4 mb-4">
|
||||
<h3 class="text-xl font-semibold text-white">Nostr Signing Request</h3>
|
||||
<button
|
||||
@click="deny"
|
||||
class="p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 mb-6">
|
||||
<div class="bg-black/20 rounded-xl border border-white/10 p-3">
|
||||
<p class="text-white/50 text-xs uppercase tracking-wider mb-1">App</p>
|
||||
<p class="text-white text-sm font-medium">{{ appName }}</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-black/20 rounded-xl border border-white/10 p-3">
|
||||
<p class="text-white/50 text-xs uppercase tracking-wider mb-1">Method</p>
|
||||
<p class="text-white text-sm font-medium">{{ method }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="contentPreview" class="bg-black/20 rounded-xl border border-white/10 p-3">
|
||||
<p class="text-white/50 text-xs uppercase tracking-wider mb-1">Content</p>
|
||||
<p class="text-white/80 text-sm font-mono break-all">{{ contentPreview }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="eventKind !== undefined" class="bg-black/20 rounded-xl border border-white/10 p-3">
|
||||
<p class="text-white/50 text-xs uppercase tracking-wider mb-1">Event Kind</p>
|
||||
<p class="text-white text-sm font-medium">{{ eventKind }} <span class="text-white/50">({{ eventKindLabel }})</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 mb-4 cursor-pointer">
|
||||
<input
|
||||
v-model="rememberChoice"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 rounded border-white/30 bg-white/10 text-orange-400 focus:ring-orange-400/50"
|
||||
/>
|
||||
<span class="text-white/70 text-sm">Remember for this app</span>
|
||||
</label>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button @click="deny" class="glass-button flex-1 py-2.5 rounded-lg text-sm font-medium">
|
||||
Deny
|
||||
</button>
|
||||
<button @click="approve" class="glass-button flex-1 py-2.5 rounded-lg text-sm font-medium text-orange-400 border-orange-400/30">
|
||||
Approve
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||
|
||||
const EVENT_KIND_LABELS: Record<number, string> = {
|
||||
0: 'Metadata',
|
||||
1: 'Short Text Note',
|
||||
2: 'Recommend Relay',
|
||||
3: 'Contacts',
|
||||
4: 'Encrypted DM',
|
||||
5: 'Event Deletion',
|
||||
6: 'Repost',
|
||||
7: 'Reaction',
|
||||
9734: 'Zap Request',
|
||||
9735: 'Zap Receipt',
|
||||
10002: 'Relay List',
|
||||
30023: 'Long-form Content',
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
appName: string
|
||||
method: string
|
||||
eventKind?: number
|
||||
content?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
approve: [remember: boolean]
|
||||
deny: []
|
||||
}>()
|
||||
|
||||
const modalRef = ref<HTMLElement | null>(null)
|
||||
const rememberChoice = ref(false)
|
||||
|
||||
useModalKeyboard(modalRef, computed(() => props.show), () => emit('deny'))
|
||||
|
||||
const contentPreview = computed(() => {
|
||||
if (!props.content) return ''
|
||||
return props.content.length > 200 ? props.content.slice(0, 200) + '...' : props.content
|
||||
})
|
||||
|
||||
const eventKindLabel = computed(() => {
|
||||
if (props.eventKind === undefined) return ''
|
||||
return EVENT_KIND_LABELS[props.eventKind] ?? 'Unknown'
|
||||
})
|
||||
|
||||
function approve() {
|
||||
emit('approve', rememberChoice.value)
|
||||
}
|
||||
|
||||
function deny() {
|
||||
emit('deny')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-enter-active .glass-card,
|
||||
.modal-leave-active .glass-card {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from .glass-card {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.modal-leave-to .glass-card {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
</style>
|
||||
25
neode-ui/src/components/OnlineStatusPill.vue
Normal file
25
neode-ui/src/components/OnlineStatusPill.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
data-controller-ignore
|
||||
class="w-full flex items-center gap-2 text-white/80 hover:text-white transition-colors"
|
||||
title="Open CLI (F)"
|
||||
@click="openCLI"
|
||||
>
|
||||
<div class="relative shrink-0">
|
||||
<div class="w-2 h-2 rounded-full bg-green-400"></div>
|
||||
<div class="absolute inset-0 w-2 h-2 rounded-full bg-green-400 animate-ping opacity-50"></div>
|
||||
</div>
|
||||
<span class="text-xs font-medium">Online</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useCLIStore } from '@/stores/cli'
|
||||
|
||||
const cliStore = useCLIStore()
|
||||
|
||||
function openCLI() {
|
||||
cliStore.open()
|
||||
}
|
||||
</script>
|
||||
91
neode-ui/src/components/PWAInstallPrompt.vue
Normal file
91
neode-ui/src/components/PWAInstallPrompt.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="showInstallPrompt"
|
||||
class="fixed bottom-4 left-4 right-4 md:left-auto md:right-6 md:bottom-6 md:max-w-sm z-[9998]"
|
||||
>
|
||||
<div class="glass-card p-4 flex items-center gap-4 shadow-xl">
|
||||
<img
|
||||
src="/assets/icon/pwa-192x192-v2.png"
|
||||
alt="Archipelago"
|
||||
class="w-14 h-14 rounded-xl shrink-0"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-white font-medium">Install Archipelago</p>
|
||||
<p class="text-white/70 text-sm">Add to your home screen for quick access</p>
|
||||
</div>
|
||||
<div class="flex gap-2 shrink-0">
|
||||
<button
|
||||
@click="dismiss"
|
||||
class="px-3 py-2 text-sm text-white/70 hover:text-white transition-colors"
|
||||
>
|
||||
Not now
|
||||
</button>
|
||||
<button
|
||||
@click="install"
|
||||
class="px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium"
|
||||
>
|
||||
Install
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
const showInstallPrompt = ref(false)
|
||||
let deferredPrompt: { prompt: () => Promise<{ outcome: string }> } | null = null
|
||||
const DISMISS_KEY = 'archipelago_pwa_install_dismissed'
|
||||
|
||||
onMounted(() => {
|
||||
// Don't show if already dismissed this session or if already installed
|
||||
if (sessionStorage.getItem(DISMISS_KEY) === '1') return
|
||||
if (window.matchMedia('(display-mode: standalone)').matches) return
|
||||
if ((window.navigator as Navigator & { standalone?: boolean }).standalone) return
|
||||
|
||||
const handler = (e: Event) => {
|
||||
e.preventDefault()
|
||||
deferredPrompt = e as unknown as { prompt: () => Promise<{ outcome: string }> }
|
||||
showInstallPrompt.value = true
|
||||
}
|
||||
|
||||
window.addEventListener('beforeinstallprompt', handler)
|
||||
;(window as Window & { __beforeinstallpromptHandler?: EventListener }).__beforeinstallpromptHandler = handler
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('beforeinstallprompt', (window as Window & { __beforeinstallpromptHandler?: EventListener }).__beforeinstallpromptHandler as EventListener)
|
||||
})
|
||||
|
||||
function dismiss() {
|
||||
showInstallPrompt.value = false
|
||||
sessionStorage.setItem(DISMISS_KEY, '1')
|
||||
}
|
||||
|
||||
async function install() {
|
||||
if (!deferredPrompt) return
|
||||
const result = await deferredPrompt.prompt()
|
||||
const outcome = result?.outcome ?? 'dismissed'
|
||||
showInstallPrompt.value = false
|
||||
deferredPrompt = null
|
||||
if (outcome === 'accepted') {
|
||||
sessionStorage.removeItem(DISMISS_KEY)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
120
neode-ui/src/components/PWAUpdatePrompt.vue
Normal file
120
neode-ui/src/components/PWAUpdatePrompt.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="showUpdatePrompt"
|
||||
class="fixed inset-0 z-[9999] flex items-center justify-center p-4"
|
||||
@click.self="dismissUpdate"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
||||
<div
|
||||
ref="modalRef"
|
||||
class="glass-card p-6 max-w-md w-full relative z-10"
|
||||
@click.stop
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4 mb-4">
|
||||
<h3 class="text-xl font-semibold text-white">Update Available</h3>
|
||||
<button
|
||||
@click="dismissUpdate"
|
||||
class="p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-white/80 mb-6">
|
||||
A new version of Archipelago is available. Update now to get the latest features and fixes.
|
||||
</p>
|
||||
<div class="flex gap-3 justify-end">
|
||||
<button
|
||||
@click="dismissUpdate"
|
||||
class="px-4 py-2 glass-button rounded-lg text-sm font-medium"
|
||||
>
|
||||
Later
|
||||
</button>
|
||||
<button
|
||||
@click="handleUpdate"
|
||||
class="px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium"
|
||||
>
|
||||
Update Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||
|
||||
const showUpdatePrompt = ref(false)
|
||||
let updateCallback: (() => Promise<void>) | null = null
|
||||
const modalRef = ref<HTMLElement | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
// Listen for service worker updates
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
// Service worker updated, reload the page
|
||||
window.location.reload()
|
||||
})
|
||||
|
||||
// Check for updates periodically
|
||||
const checkForUpdates = async () => {
|
||||
const registration = await navigator.serviceWorker.getRegistration()
|
||||
if (registration) {
|
||||
await registration.update()
|
||||
}
|
||||
}
|
||||
|
||||
// Check for updates every 5 minutes
|
||||
setInterval(checkForUpdates, 5 * 60 * 1000)
|
||||
|
||||
// Check when user returns to tab (helps with cached PWA)
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
checkForUpdates()
|
||||
}
|
||||
})
|
||||
|
||||
// Listen for updatefound event
|
||||
navigator.serviceWorker.getRegistration().then((registration) => {
|
||||
if (registration) {
|
||||
registration.addEventListener('updatefound', () => {
|
||||
const newWorker = registration.installing
|
||||
if (newWorker) {
|
||||
newWorker.addEventListener('statechange', () => {
|
||||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
// New service worker installed, show update prompt
|
||||
showUpdatePrompt.value = true
|
||||
updateCallback = async () => {
|
||||
if (newWorker.state === 'installed' && registration.waiting) {
|
||||
// Skip waiting and activate the new service worker
|
||||
registration.waiting.postMessage({ type: 'SKIP_WAITING' })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
useModalKeyboard(modalRef, showUpdatePrompt, dismissUpdate)
|
||||
|
||||
function dismissUpdate() {
|
||||
showUpdatePrompt.value = false
|
||||
}
|
||||
|
||||
async function handleUpdate() {
|
||||
if (updateCallback) {
|
||||
await updateCallback()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
183
neode-ui/src/components/Screensaver.vue
Normal file
183
neode-ui/src/components/Screensaver.vue
Normal file
@@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="screensaver">
|
||||
<div
|
||||
v-if="store.isActive"
|
||||
class="screensaver-container fixed inset-0 z-[3000] bg-black cursor-pointer"
|
||||
@click="store.deactivate()"
|
||||
@keydown.escape="store.deactivate()"
|
||||
>
|
||||
<!-- Logo with audio viz ring - explicitly centered in viewport -->
|
||||
<div class="screensaver-content">
|
||||
<!-- Radial audio visualization - bars around the logo -->
|
||||
<div class="screensaver-viz-ring">
|
||||
<div
|
||||
v-for="(_, i) in segmentCount"
|
||||
:key="i"
|
||||
class="screensaver-viz-segment"
|
||||
:style="getSegmentStyle(i)"
|
||||
/>
|
||||
</div>
|
||||
<!-- Logo in center -->
|
||||
<div class="screensaver-logo-wrapper">
|
||||
<ScreensaverLogo />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onBeforeUnmount } from 'vue'
|
||||
import ScreensaverLogo from '@/components/ScreensaverLogo.vue'
|
||||
import { useScreensaverStore } from '@/stores/screensaver'
|
||||
|
||||
const store = useScreensaverStore()
|
||||
|
||||
const segmentCount = 48
|
||||
|
||||
function getSegmentStyle(i: number) {
|
||||
const deg = (i / segmentCount) * 360
|
||||
return {
|
||||
'--segment-index': i,
|
||||
'--segment-deg': `${deg}deg`,
|
||||
}
|
||||
}
|
||||
|
||||
// Dismiss on any key (except when typing)
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (store.isActive) {
|
||||
store.deactivate()
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.screensaver-enter-active,
|
||||
.screensaver-leave-active {
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
.screensaver-enter-from,
|
||||
.screensaver-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Explicit viewport centering */
|
||||
.screensaver-container {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.screensaver-content {
|
||||
position: relative;
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.screensaver-content {
|
||||
width: 360px;
|
||||
height: 360px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.screensaver-content {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ring of segments around the logo - audio viz style (behind logo) */
|
||||
.screensaver-viz-ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
--viz-radius: 140px;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.screensaver-viz-ring {
|
||||
--viz-radius: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.screensaver-viz-ring {
|
||||
--viz-radius: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.screensaver-viz-segment {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 4px;
|
||||
height: 24px;
|
||||
margin-left: -2px;
|
||||
margin-top: -12px;
|
||||
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.1));
|
||||
border-radius: 2px;
|
||||
/* Origin at segment center = ring center (segment is centered via left/top 50%) */
|
||||
transform-origin: center center;
|
||||
transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius)));
|
||||
animation: segment-pulse 14s ease-in-out infinite;
|
||||
animation-delay: calc(var(--segment-index) * 0.02s);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.screensaver-viz-segment {
|
||||
height: 28px;
|
||||
margin-top: -14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 5 normal loops (10s) then stronger longer expression (4s) - total 14s */
|
||||
@keyframes segment-pulse {
|
||||
/* Loop 1 */
|
||||
0% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
|
||||
7.1% { opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
|
||||
14.3% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
|
||||
/* Loop 2 */
|
||||
21.4% { opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
|
||||
28.6% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
|
||||
/* Loop 3 */
|
||||
35.7% { opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
|
||||
42.9% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
|
||||
/* Loop 4 */
|
||||
50% { opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
|
||||
57.1% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
|
||||
/* Loop 5 */
|
||||
64.3% { opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
|
||||
71.4% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
|
||||
/* Strong expression: ramp up (1.5s), hold (2s), ease back (0.5s) */
|
||||
78.6% { opacity: 1; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1.5); }
|
||||
85.7% { opacity: 1; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1.5); }
|
||||
92.9% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
|
||||
100% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
|
||||
}
|
||||
|
||||
.screensaver-logo-wrapper {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 10;
|
||||
filter: drop-shadow(0 0 40px rgba(255, 255, 255, 0.15));
|
||||
}
|
||||
</style>
|
||||
20
neode-ui/src/components/ScreensaverLogo.vue
Normal file
20
neode-ui/src/components/ScreensaverLogo.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="logo-gradient-border screensaver-logo-cycle relative w-48 h-48 sm:w-64 sm:h-64 md:w-80 md:h-80 flex items-center justify-center overflow-hidden">
|
||||
<!-- Squares logo -->
|
||||
<div class="screensaver-logo-squares absolute inset-[3px] flex items-center justify-center">
|
||||
<AnimatedLogo size="xl" no-border fit />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.screensaver-logo-squares {
|
||||
opacity: 1;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
52
neode-ui/src/components/SkeletonCard.vue
Normal file
52
neode-ui/src/components/SkeletonCard.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div class="glass-card p-6 animate-pulse" :class="className">
|
||||
<!-- Header skeleton -->
|
||||
<div v-if="showHeader" class="flex items-start gap-4 mb-4">
|
||||
<div class="w-12 h-12 rounded-lg bg-white/10 shrink-0"></div>
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="h-4 bg-white/10 rounded w-2/3"></div>
|
||||
<div class="h-3 bg-white/5 rounded w-1/3"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content skeleton lines -->
|
||||
<div class="space-y-3">
|
||||
<div v-for="i in lines" :key="i" class="h-3 bg-white/8 rounded" :style="{ width: lineWidth(i) }"></div>
|
||||
</div>
|
||||
|
||||
<!-- Stats grid skeleton -->
|
||||
<div v-if="showStats" class="grid grid-cols-2 md:grid-cols-4 gap-3 mt-4">
|
||||
<div v-for="s in 4" :key="s" class="bg-white/5 rounded-lg p-3">
|
||||
<div class="h-2 bg-white/8 rounded w-1/2 mb-2"></div>
|
||||
<div class="h-5 bg-white/10 rounded w-3/4"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons skeleton -->
|
||||
<div v-if="showActions" class="flex gap-3 mt-4">
|
||||
<div class="h-9 bg-white/8 rounded-lg flex-1"></div>
|
||||
<div class="h-9 bg-white/8 rounded-lg flex-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(defineProps<{
|
||||
lines?: number
|
||||
showHeader?: boolean
|
||||
showStats?: boolean
|
||||
showActions?: boolean
|
||||
className?: string
|
||||
}>(), {
|
||||
lines: 3,
|
||||
showHeader: true,
|
||||
showStats: false,
|
||||
showActions: false,
|
||||
className: '',
|
||||
})
|
||||
|
||||
function lineWidth(index: number): string {
|
||||
const widths = ['100%', '85%', '70%', '90%', '60%']
|
||||
return widths[(index - 1) % widths.length] ?? '100%'
|
||||
}
|
||||
</script>
|
||||
783
neode-ui/src/components/SplashScreen.vue
Normal file
783
neode-ui/src/components/SplashScreen.vue
Normal file
@@ -0,0 +1,783 @@
|
||||
<template>
|
||||
<Transition name="splash-fade">
|
||||
<div v-if="showSplash" class="fixed inset-0 z-[2000] flex items-center justify-center bg-black" style="will-change: opacity, transform;">
|
||||
<!-- Video background - shown during Welcome Noderunner and Logo (seamless, no zoom) -->
|
||||
<video
|
||||
v-if="showWelcome || showLogo"
|
||||
ref="videoElement"
|
||||
class="absolute inset-0 w-full h-full object-cover"
|
||||
:style="{ opacity: backgroundOpacity, transform: 'scale(1)', transition: 'opacity 1.2s ease-out' }"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
preload="auto"
|
||||
poster="/assets/img/bg-intro.jpg"
|
||||
>
|
||||
<source src="/assets/video/video-intro.mp4?v=7" type="video/mp4">
|
||||
<!-- Fallback to image if video fails -->
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
:style="{
|
||||
backgroundImage: 'url(/assets/img/bg-intro.jpg)',
|
||||
backgroundSize: 'auto 100vh',
|
||||
backgroundPosition: 'center top',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}"
|
||||
/>
|
||||
</video>
|
||||
|
||||
<!-- Static image background - shown during alien intro -->
|
||||
<div
|
||||
v-else
|
||||
class="absolute inset-0"
|
||||
:style="{
|
||||
backgroundImage: 'url(/assets/img/bg-intro.jpg)',
|
||||
backgroundSize: 'auto 100vh',
|
||||
backgroundPosition: 'center top',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
opacity: backgroundOpacity,
|
||||
transform: 'scale(1)',
|
||||
transition: 'opacity 1.2s ease-out',
|
||||
}"
|
||||
/>
|
||||
|
||||
<!-- Alien Intro -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="!alienIntroComplete"
|
||||
class="absolute inset-0 z-10 flex items-center justify-center transition-opacity duration-800"
|
||||
:class="{ 'opacity-0': fadeAlienIntro }"
|
||||
>
|
||||
<div class="font-mono text-white px-4 sm:px-5 max-w-[95vw] sm:max-w-[90vw] md:max-w-[1200px] text-base sm:text-lg md:text-[24px] leading-relaxed break-words">
|
||||
<div v-if="showLine1" class="flex items-start mb-4 sm:mb-6 opacity-0" :class="{ 'opacity-100': showLine1 }">
|
||||
<span class="text-[#fbbf24] mr-3 sm:mr-6 flex-shrink-0">></span>
|
||||
<span class="text-white break-words">{{ displayLine1 }}</span><span v-if="isTypingLine1" class="intro-typing-caret" aria-hidden="true"></span>
|
||||
</div>
|
||||
<div v-if="showLine2" class="flex items-start mb-4 sm:mb-6 opacity-0" :class="{ 'opacity-100': showLine2 }">
|
||||
<span class="text-[#fbbf24] mr-3 sm:mr-6 flex-shrink-0">></span>
|
||||
<span class="text-white break-words">{{ displayLine2 }}</span><span v-if="isTypingLine2" class="intro-typing-caret" aria-hidden="true"></span>
|
||||
</div>
|
||||
<div v-if="showLine3" class="flex items-start mb-4 sm:mb-6 opacity-0" :class="{ 'opacity-100': showLine3 }">
|
||||
<span class="text-[#fbbf24] mr-3 sm:mr-6 flex-shrink-0">></span>
|
||||
<span class="text-white break-words">{{ displayLine3 }}</span><span v-if="isTypingLine3" class="intro-typing-caret" aria-hidden="true"></span>
|
||||
</div>
|
||||
<div v-if="showLine4" class="flex items-start mb-8 sm:mb-12 opacity-0" :class="{ 'opacity-100': showLine4 }">
|
||||
<span class="text-[#fbbf24] mr-3 sm:mr-6 flex-shrink-0">></span>
|
||||
<span class="text-white break-words">{{ displayLine4 }}</span><span v-if="isTypingLine4" class="intro-typing-caret" aria-hidden="true"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Welcome Message -->
|
||||
<Transition name="welcome-fade">
|
||||
<div
|
||||
v-if="showWelcome"
|
||||
class="absolute inset-0 z-[15] flex items-center justify-center font-mono text-3xl sm:text-4xl md:text-5xl px-4"
|
||||
:class="{ 'welcome-fade-out': fadeWelcome }"
|
||||
>
|
||||
<div class="typing-container">
|
||||
<span class="text-white" :class="{ 'typing-text': typingWelcome }">
|
||||
Welcome Noderunner
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Logo - Archipelago logo for splash -->
|
||||
<Transition name="logo-zoom">
|
||||
<div v-if="showLogo" class="relative z-20 logo-container">
|
||||
<img
|
||||
src="/assets/img/logo-archipelago.svg"
|
||||
alt="Archipelago"
|
||||
class="w-[min(80vw,900px)] max-w-[90vw] h-auto filter drop-shadow-[0_6px_24px_rgba(0,0,0,0.35)] m-5 object-contain logo-zoom-bounce"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Tap to start - logo + "Enter the Exit" behind (like screensaver) -->
|
||||
<div
|
||||
v-if="showTapToStart"
|
||||
class="absolute inset-0 z-[100] flex items-center justify-center cursor-pointer overflow-hidden"
|
||||
:class="tapStartTransitioning ? 'tap-overlay-zoom-out' : 'bg-black/40'"
|
||||
@click="handleTapToStart"
|
||||
>
|
||||
<div class="tap-to-start-content relative flex items-center justify-center perspective-1000">
|
||||
<span
|
||||
class="tap-to-start-text font-archipelago font-extrabold text-6xl sm:text-7xl md:text-8xl lg:text-9xl tracking-widest uppercase whitespace-nowrap select-none transition-opacity duration-300"
|
||||
:class="{ 'opacity-0': tapStartTransitioning }"
|
||||
>
|
||||
Enter to Exit
|
||||
</span>
|
||||
<div
|
||||
class="tap-to-start-logo absolute transition-transform duration-300 ease-out"
|
||||
:class="[
|
||||
{ 'tap-logo-launch': tapStartTransitioning },
|
||||
{ 'scale-110': introLogoHover && !tapStartTransitioning }
|
||||
]"
|
||||
@mouseenter="onIntroLogoHover"
|
||||
@mouseleave="introLogoHover = false"
|
||||
>
|
||||
<!-- Audio viz ring - visible on hover -->
|
||||
<div
|
||||
class="intro-logo-viz-ring"
|
||||
:class="{ 'intro-logo-viz-visible': introLogoHover && !tapStartTransitioning }"
|
||||
>
|
||||
<div
|
||||
v-for="i in 48"
|
||||
:key="i - 1"
|
||||
class="intro-logo-viz-segment"
|
||||
:style="{ '--segment-deg': `${((i - 1) / 48) * 360}deg`, '--segment-index': i - 1 }"
|
||||
></div>
|
||||
</div>
|
||||
<ScreensaverLogo />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Skip Button -->
|
||||
<button
|
||||
v-if="!alienIntroComplete && !showTapToStart"
|
||||
@click="handleSkipClick"
|
||||
class="absolute bottom-8 right-8 z-20 bg-black/60 border border-white/30 text-white/70 font-mono text-xs px-4 py-2 rounded backdrop-blur-[10px] hover:bg-black/80 hover:text-white/90 hover:border-white/50 hover:-translate-y-0.5 active:translate-y-0 transition-all duration-300"
|
||||
>
|
||||
Skip Intro
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import ScreensaverLogo from '@/components/ScreensaverLogo.vue'
|
||||
import { playIntroTyping, playKeyboardTypingSound, playLoopStart, playPop, playWelcomeNoderunnerSpeech, resumeAudioContext, startSynthwave, stopIntroTyping } from '@/composables/useLoginSounds'
|
||||
|
||||
const emit = defineEmits<{
|
||||
complete: []
|
||||
}>()
|
||||
|
||||
const INTRO_LINES = [
|
||||
'In the future there will be 3 types of humans',
|
||||
'Government Employees',
|
||||
'Corporate Employees',
|
||||
'And Noderunners...',
|
||||
] as const
|
||||
const MS_PER_CHAR = 55
|
||||
const BLINK_AFTER_TYPING = 1500
|
||||
|
||||
const showSplash = ref(true)
|
||||
const showTapToStart = ref(true)
|
||||
const tapStartTransitioning = ref(false)
|
||||
const introLogoHover = ref(false)
|
||||
const backgroundOpacity = ref(0)
|
||||
const alienIntroComplete = ref(false)
|
||||
const fadeAlienIntro = ref(false)
|
||||
const showWelcome = ref(false)
|
||||
const fadeWelcome = ref(false)
|
||||
const typingWelcome = ref(false)
|
||||
const showLogo = ref(false)
|
||||
const showLine1 = ref(false)
|
||||
const showLine2 = ref(false)
|
||||
const showLine3 = ref(false)
|
||||
const showLine4 = ref(false)
|
||||
const displayLine1 = ref('')
|
||||
const displayLine2 = ref('')
|
||||
const displayLine3 = ref('')
|
||||
const displayLine4 = ref('')
|
||||
const isTypingLine1 = ref(false)
|
||||
const isTypingLine2 = ref(false)
|
||||
const isTypingLine3 = ref(false)
|
||||
const isTypingLine4 = ref(false)
|
||||
const videoElement = ref<HTMLVideoElement | null>(null)
|
||||
let introTypingTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
const pendingTimers: ReturnType<typeof setTimeout>[] = []
|
||||
|
||||
function scheduleTimer(fn: () => void, delay: number) {
|
||||
const id = setTimeout(fn, delay)
|
||||
pendingTimers.push(id)
|
||||
return id
|
||||
}
|
||||
|
||||
// Ensure video plays continuously from Welcome Noderunner through logo
|
||||
let videoPauseHandler: ((e: Event) => void) | null = null
|
||||
watch([showWelcome, showLogo], ([welcome, logo]) => {
|
||||
if ((welcome || logo) && videoElement.value) {
|
||||
if (videoElement.value.paused) {
|
||||
videoElement.value.play().catch(err => {
|
||||
if (import.meta.env.DEV) console.warn('Video autoplay failed:', err)
|
||||
})
|
||||
}
|
||||
// Add pause prevention handler once, remove when no longer needed
|
||||
if (!videoPauseHandler) {
|
||||
videoPauseHandler = () => {
|
||||
if ((showWelcome.value || showLogo.value) && videoElement.value) {
|
||||
videoElement.value.play().catch(() => {})
|
||||
}
|
||||
}
|
||||
videoElement.value.addEventListener('pause', videoPauseHandler)
|
||||
}
|
||||
} else if (videoPauseHandler && videoElement.value) {
|
||||
videoElement.value.removeEventListener('pause', videoPauseHandler)
|
||||
videoPauseHandler = null
|
||||
}
|
||||
})
|
||||
|
||||
// Start video as soon as welcome appears
|
||||
watch(showWelcome, (isShowing) => {
|
||||
if (isShowing && videoElement.value) {
|
||||
// Start video immediately when welcome appears
|
||||
videoElement.value.play().catch(err => {
|
||||
if (import.meta.env.DEV) console.warn('Video autoplay failed on welcome:', err)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Store video currentTime continuously and before unmounting for seamless transition
|
||||
watch(showSplash, (isShowing) => {
|
||||
if (!isShowing && videoElement.value) {
|
||||
// Store current video time for seamless transition
|
||||
const currentTime = videoElement.value.currentTime
|
||||
const wasPlaying = !videoElement.value.paused
|
||||
sessionStorage.setItem('video_intro_currentTime', currentTime.toString())
|
||||
sessionStorage.setItem('video_intro_wasPlaying', wasPlaying.toString())
|
||||
// Store video playback rate to maintain smooth playback
|
||||
sessionStorage.setItem('video_intro_playbackRate', videoElement.value.playbackRate.toString())
|
||||
}
|
||||
})
|
||||
|
||||
// Continuously update video time while playing (for more accurate restoration)
|
||||
let videoTimeUpdateInterval: number | null = null
|
||||
watch([showWelcome, showLogo], ([welcome, logo]) => {
|
||||
if ((welcome || logo) && videoElement.value) {
|
||||
// Update stored time every 50ms for better accuracy and smoother transition
|
||||
videoTimeUpdateInterval = window.setInterval(() => {
|
||||
if (videoElement.value && !videoElement.value.paused) {
|
||||
sessionStorage.setItem('video_intro_currentTime', videoElement.value.currentTime.toString())
|
||||
sessionStorage.setItem('video_intro_wasPlaying', 'true')
|
||||
sessionStorage.setItem('video_intro_playbackRate', videoElement.value.playbackRate.toString())
|
||||
}
|
||||
}, 50) // More frequent updates for smoother transition
|
||||
} else {
|
||||
if (videoTimeUpdateInterval) {
|
||||
clearInterval(videoTimeUpdateInterval)
|
||||
videoTimeUpdateInterval = null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Check if user has seen intro
|
||||
// Also detect returning users who cleared cache: if we're on a /dashboard route,
|
||||
// the backend session is still active so the user has been here before.
|
||||
const storedSeenIntro = localStorage.getItem('neode_intro_seen') === '1'
|
||||
const isOnDashboard = window.location.pathname.startsWith('/dashboard')
|
||||
const seenIntro = storedSeenIntro || isOnDashboard
|
||||
// Persist the flag so subsequent loads don't re-check
|
||||
if (!storedSeenIntro && isOnDashboard) {
|
||||
localStorage.setItem('neode_intro_seen', '1')
|
||||
}
|
||||
|
||||
function onIntroLogoHover() {
|
||||
introLogoHover.value = true
|
||||
if (!tapStartTransitioning.value) playKeyboardTypingSound()
|
||||
}
|
||||
|
||||
function handleTapToStart() {
|
||||
if (!showTapToStart.value || tapStartTransitioning.value) return
|
||||
resumeAudioContext()
|
||||
playPop()
|
||||
tapStartTransitioning.value = true
|
||||
// Logo: grow (150ms) then zoom out to background (850ms). Total 1s.
|
||||
setTimeout(() => {
|
||||
showTapToStart.value = false
|
||||
tapStartTransitioning.value = false
|
||||
startAlienIntro()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function handleSkipClick() {
|
||||
resumeAudioContext()
|
||||
skipIntro()
|
||||
}
|
||||
|
||||
function skipIntro() {
|
||||
// Jump to "Welcome Noderunner" part
|
||||
if (introTypingTimeout) {
|
||||
clearTimeout(introTypingTimeout)
|
||||
introTypingTimeout = null
|
||||
}
|
||||
alienIntroComplete.value = true
|
||||
fadeAlienIntro.value = true
|
||||
showWelcome.value = true
|
||||
typingWelcome.value = true
|
||||
stopIntroTyping()
|
||||
playLoopStart()
|
||||
startSynthwave()
|
||||
playWelcomeNoderunnerSpeech()
|
||||
|
||||
// Stop alien intro typing and any playing typing sound
|
||||
stopIntroTyping()
|
||||
isTypingLine1.value = false
|
||||
isTypingLine2.value = false
|
||||
isTypingLine3.value = false
|
||||
isTypingLine4.value = false
|
||||
|
||||
// Start background fade in at 0.3 opacity when welcome appears
|
||||
scheduleTimer(() => {
|
||||
backgroundOpacity.value = 0.3
|
||||
}, 0)
|
||||
|
||||
// Continue with welcome fade out after typing (2s) + cursor continues (1.5s) + 3 blinks (1.35s)
|
||||
scheduleTimer(() => {
|
||||
fadeWelcome.value = true
|
||||
typingWelcome.value = false
|
||||
}, 4850)
|
||||
|
||||
// Show logo - no zoom, just fade
|
||||
scheduleTimer(() => {
|
||||
showLogo.value = true
|
||||
}, 5500)
|
||||
|
||||
// Hide welcome after logo starts appearing
|
||||
scheduleTimer(() => {
|
||||
showWelcome.value = false
|
||||
}, 6000)
|
||||
|
||||
// Fade background to full opacity just before completing (for smooth transition to modal)
|
||||
scheduleTimer(() => {
|
||||
backgroundOpacity.value = 1
|
||||
}, 9000)
|
||||
|
||||
// Complete splash with smooth transition
|
||||
scheduleTimer(() => {
|
||||
scheduleTimer(() => {
|
||||
showSplash.value = false
|
||||
document.body.classList.add('splash-complete')
|
||||
localStorage.setItem('neode_intro_seen', '1')
|
||||
emit('complete')
|
||||
}, 500)
|
||||
}, 9500)
|
||||
}
|
||||
|
||||
function startAlienIntro() {
|
||||
function typeLine(
|
||||
lineIndex: number,
|
||||
displayRef: { value: string },
|
||||
isTypingRef: { value: boolean },
|
||||
onDone: () => void
|
||||
) {
|
||||
const text = INTRO_LINES[lineIndex]!
|
||||
let i = 0
|
||||
displayRef.value = ''
|
||||
isTypingRef.value = true
|
||||
|
||||
function tick() {
|
||||
if (i === 0) {
|
||||
playIntroTyping()
|
||||
}
|
||||
if (i < text.length) {
|
||||
displayRef.value = text.slice(0, i + 1)
|
||||
i++
|
||||
introTypingTimeout = setTimeout(tick, MS_PER_CHAR)
|
||||
} else {
|
||||
stopIntroTyping()
|
||||
isTypingRef.value = false
|
||||
introTypingTimeout = setTimeout(onDone, BLINK_AFTER_TYPING)
|
||||
}
|
||||
}
|
||||
tick()
|
||||
}
|
||||
|
||||
function scheduleLine1() {
|
||||
showLine1.value = true
|
||||
typeLine(0, displayLine1, isTypingLine1, scheduleLine2)
|
||||
}
|
||||
|
||||
function scheduleLine2() {
|
||||
showLine2.value = true
|
||||
typeLine(1, displayLine2, isTypingLine2, scheduleLine3)
|
||||
}
|
||||
|
||||
function scheduleLine3() {
|
||||
showLine3.value = true
|
||||
typeLine(2, displayLine3, isTypingLine3, scheduleLine4)
|
||||
}
|
||||
|
||||
function scheduleLine4() {
|
||||
showLine4.value = true
|
||||
typeLine(3, displayLine4, isTypingLine4, () => {
|
||||
isTypingLine4.value = false
|
||||
fadeAlienIntro.value = true
|
||||
introTypingTimeout = setTimeout(showWelcomePhase, 800)
|
||||
})
|
||||
}
|
||||
|
||||
function showWelcomePhase() {
|
||||
alienIntroComplete.value = true
|
||||
showWelcome.value = true
|
||||
typingWelcome.value = true
|
||||
stopIntroTyping()
|
||||
playLoopStart()
|
||||
startSynthwave()
|
||||
playWelcomeNoderunnerSpeech()
|
||||
if (videoElement.value) {
|
||||
videoElement.value.play().catch(err => {
|
||||
if (import.meta.env.DEV) console.warn('Video autoplay failed on welcome:', err)
|
||||
})
|
||||
}
|
||||
backgroundOpacity.value = 0.3
|
||||
|
||||
scheduleTimer(() => {
|
||||
fadeWelcome.value = true
|
||||
typingWelcome.value = false
|
||||
}, 4850)
|
||||
|
||||
scheduleTimer(() => {
|
||||
showLogo.value = true
|
||||
}, 5500)
|
||||
|
||||
scheduleTimer(() => {
|
||||
showWelcome.value = false
|
||||
}, 6000)
|
||||
|
||||
scheduleTimer(() => {
|
||||
backgroundOpacity.value = 1
|
||||
}, 9000)
|
||||
|
||||
scheduleTimer(() => {
|
||||
if (videoElement.value && !videoElement.value.paused) {
|
||||
sessionStorage.setItem('video_intro_currentTime', videoElement.value.currentTime.toString())
|
||||
sessionStorage.setItem('video_intro_wasPlaying', 'true')
|
||||
}
|
||||
showSplash.value = false
|
||||
document.body.classList.add('splash-complete')
|
||||
localStorage.setItem('neode_intro_seen', '1')
|
||||
emit('complete')
|
||||
}, 9500)
|
||||
}
|
||||
|
||||
introTypingTimeout = setTimeout(scheduleLine1, 500)
|
||||
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (seenIntro) {
|
||||
showSplash.value = false
|
||||
document.body.classList.add('splash-complete')
|
||||
emit('complete')
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (introTypingTimeout) {
|
||||
clearTimeout(introTypingTimeout)
|
||||
introTypingTimeout = null
|
||||
}
|
||||
// Clear all scheduled timers to prevent firing on unmounted component
|
||||
for (const id of pendingTimers) clearTimeout(id)
|
||||
pendingTimers.length = 0
|
||||
// Clear video time update interval
|
||||
if (videoTimeUpdateInterval) {
|
||||
clearInterval(videoTimeUpdateInterval)
|
||||
videoTimeUpdateInterval = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.splash-fade-enter-active {
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
.splash-fade-leave-active {
|
||||
transition: opacity 1s ease-out, transform 1s ease-out;
|
||||
}
|
||||
|
||||
.splash-fade-enter-from,
|
||||
.splash-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.splash-fade-leave-to {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Wide logo zooms out towards user when login modal comes in */
|
||||
.splash-fade-leave-active .logo-container {
|
||||
transition: transform 1s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.splash-fade-leave-to .logo-container {
|
||||
transform: scale(1.4);
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Welcome message fade out */
|
||||
.welcome-fade-enter-active {
|
||||
transition: opacity 0.8s ease-out;
|
||||
}
|
||||
|
||||
.welcome-fade-leave-active {
|
||||
transition: opacity 0.6s ease-in;
|
||||
}
|
||||
|
||||
.welcome-fade-enter-from,
|
||||
.welcome-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.welcome-fade-out {
|
||||
opacity: 0;
|
||||
transition: opacity 0.6s ease-in;
|
||||
}
|
||||
|
||||
/* Logo zoom bounce animation - smooth and buttery */
|
||||
.logo-zoom-enter-active {
|
||||
transition: all 1s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
.logo-zoom-leave-active {
|
||||
transition: opacity 0.5s ease-out, transform 0.5s ease-out;
|
||||
}
|
||||
|
||||
.logo-zoom-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(0.7);
|
||||
}
|
||||
|
||||
.logo-zoom-enter-to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.logo-zoom-leave-from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.logo-zoom-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.logo-zoom-bounce {
|
||||
animation: logoZoomBounce 1.2s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
|
||||
}
|
||||
|
||||
@keyframes logoZoomBounce {
|
||||
0% {
|
||||
transform: scale(0.85);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.02);
|
||||
opacity: 0.9;
|
||||
}
|
||||
75% {
|
||||
transform: scale(0.98);
|
||||
opacity: 0.95;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Container to keep the typing text centered */
|
||||
.typing-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Intro typing cursor - block style, yellow blink (Archipelago style) */
|
||||
.intro-typing-caret {
|
||||
display: inline-block;
|
||||
width: 4px;
|
||||
min-width: 4px;
|
||||
height: 1.2em;
|
||||
background: #fbbf24;
|
||||
margin-left: 2px;
|
||||
vertical-align: text-bottom;
|
||||
animation: intro-caret-blink 0.5s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes intro-caret-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Ensure text wraps smoothly on mobile */
|
||||
.font-mono {
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
/* Smooth line transitions for mobile */
|
||||
@media (max-width: 640px) {
|
||||
.font-mono span {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Background zoom transition - matches OnboardingWrapper style */
|
||||
.bg-zoom-transition {
|
||||
transition: transform 1.5s cubic-bezier(0.4, 0, 0.2, 1), opacity 1.2s ease-out;
|
||||
transform: scale(1);
|
||||
transform-origin: center center;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.bg-zoom-transition.bg-zoom-in {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
/* Tap to start - logo grow then zoom out to background */
|
||||
.tap-overlay-zoom-out {
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
transition: background-color 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
animation: tap-overlay-fade 1s ease-out forwards;
|
||||
}
|
||||
@keyframes tap-overlay-fade {
|
||||
0% { background-color: rgba(0, 0, 0, 0.4); }
|
||||
30% { background-color: rgba(0, 0, 0, 0.35); }
|
||||
100% { background-color: rgba(0, 0, 0, 0); }
|
||||
}
|
||||
.perspective-1000 {
|
||||
perspective: 1000px;
|
||||
}
|
||||
.tap-logo-launch {
|
||||
animation: tap-logo-launch 1s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
transform-origin: center center;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
@keyframes tap-logo-launch {
|
||||
0% { transform: scale(1); opacity: 1; }
|
||||
15% { transform: scale(1.2); opacity: 1; }
|
||||
25% { transform: scale(1.15); opacity: 1; }
|
||||
100% { transform: scale(0); opacity: 0; }
|
||||
}
|
||||
|
||||
/* Tap to start - "Enter the Exit" big behind logo */
|
||||
.tap-to-start-content {
|
||||
min-height: 12rem;
|
||||
}
|
||||
.tap-to-start-text {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(0, 0, 0, 0.35) 0%,
|
||||
rgba(0, 0, 0, 0.35) 38%,
|
||||
rgba(0, 0, 0, 0.35) 40%,
|
||||
rgba(255, 255, 255, 0.5) 48%,
|
||||
rgba(255, 255, 255, 0.7) 50%,
|
||||
rgba(255, 255, 255, 0.5) 52%,
|
||||
rgba(0, 0, 0, 0.35) 60%,
|
||||
rgba(0, 0, 0, 0.35) 100%
|
||||
);
|
||||
background-size: 250% 100%;
|
||||
background-position: 0% 0;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
animation: tap-to-start-flare-wipe 14s ease-in-out infinite;
|
||||
}
|
||||
@keyframes tap-to-start-flare-wipe {
|
||||
0%, 82%, 100% {
|
||||
background-position: 0% 0;
|
||||
}
|
||||
88% {
|
||||
background-position: 100% 0;
|
||||
}
|
||||
}
|
||||
.tap-to-start-logo {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
filter: drop-shadow(0 0 40px rgba(255, 255, 255, 0.15));
|
||||
overflow: visible;
|
||||
}
|
||||
.intro-logo-viz-ring {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.35s ease;
|
||||
--viz-radius: 7rem;
|
||||
}
|
||||
.intro-logo-viz-ring.intro-logo-viz-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
.intro-logo-viz-segment {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 4px;
|
||||
height: 24px;
|
||||
margin-left: -2px;
|
||||
margin-top: -12px;
|
||||
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0.15));
|
||||
border-radius: 2px;
|
||||
transform-origin: center center;
|
||||
transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius)));
|
||||
animation: intro-viz-pulse 2.5s ease-in-out infinite;
|
||||
animation-delay: calc(var(--segment-index, 0) * 0.02s);
|
||||
}
|
||||
@keyframes intro-viz-pulse {
|
||||
0%, 100% { opacity: 0.4; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.5); }
|
||||
50% { opacity: 1; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.intro-logo-viz-ring { --viz-radius: 8rem; }
|
||||
.intro-logo-viz-segment { height: 26px; margin-top: -13px; }
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.intro-logo-viz-ring { --viz-radius: 9rem; }
|
||||
.intro-logo-viz-segment { height: 28px; margin-top: -14px; }
|
||||
}
|
||||
|
||||
.tap-to-start-logo :deep(.logo-gradient-border) {
|
||||
width: 12rem;
|
||||
height: 12rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.tap-to-start-content {
|
||||
min-height: 14rem;
|
||||
}
|
||||
.tap-to-start-logo :deep(.logo-gradient-border) {
|
||||
width: 14rem;
|
||||
height: 14rem;
|
||||
}
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.tap-to-start-content {
|
||||
min-height: 16rem;
|
||||
}
|
||||
.tap-to-start-logo :deep(.logo-gradient-border) {
|
||||
width: 16rem;
|
||||
height: 16rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
413
neode-ui/src/components/SpotlightSearch.vue
Normal file
413
neode-ui/src/components/SpotlightSearch.vue
Normal file
@@ -0,0 +1,413 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="spotlight">
|
||||
<div
|
||||
v-if="spotlightStore.isOpen"
|
||||
class="fixed inset-0 z-[2500] flex items-center justify-center p-4"
|
||||
@click.self="spotlightStore.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: drag handle grip + search -->
|
||||
<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-1 flex items-center gap-3 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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
ref="inputRef"
|
||||
v-model="query"
|
||||
type="text"
|
||||
placeholder="Search or type a command..."
|
||||
class="flex-1 bg-transparent text-white placeholder-white/50 outline-none text-base"
|
||||
@keydown="onInputKeydown"
|
||||
/>
|
||||
</div>
|
||||
<kbd class="hidden sm:inline-flex px-2 py-1 text-xs text-white/50 bg-white/10 rounded">Esc</kbd>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto max-h-[60vh] min-h-[200px]">
|
||||
<!-- Recent items (when no query and we have recent) -->
|
||||
<div v-if="!query.trim() && spotlightStore.recentItems.length > 0" class="p-2 border-b border-white/10">
|
||||
<div class="px-3 py-2 text-xs font-medium text-white/50 uppercase tracking-wider">Recent</div>
|
||||
<button
|
||||
v-for="(item, idx) in spotlightStore.recentItems"
|
||||
:key="`recent-${item.id}-${item.timestamp}`"
|
||||
type="button"
|
||||
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors"
|
||||
:class="getItemClass(idx)"
|
||||
@click="selectRecent(item)"
|
||||
>
|
||||
<span class="text-white/90">{{ item.label }}</span>
|
||||
<span class="text-xs text-white/40">{{ item.type }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search results or help tree -->
|
||||
<template v-if="query.trim()">
|
||||
<div v-if="filteredItems.length > 0" class="p-2">
|
||||
<button
|
||||
v-for="(item, idx) in filteredItems"
|
||||
:key="item.id + item.section"
|
||||
type="button"
|
||||
class="w-full flex items-center justify-between gap-3 px-3 py-2.5 rounded-lg text-left transition-colors"
|
||||
:class="getItemClass(idx)"
|
||||
@click="selectItem(item)"
|
||||
>
|
||||
<span class="text-white/90">{{ item.label }}</span>
|
||||
<span class="text-xs text-white/40">{{ item.section }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="p-8 text-center text-white/50">
|
||||
No results for "{{ query }}"
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- Help tree when no search -->
|
||||
<div v-for="section in helpTree" :key="section.id" class="p-2">
|
||||
<div class="px-3 py-2 text-xs font-medium text-white/50 uppercase tracking-wider">{{ section.label }}</div>
|
||||
<button
|
||||
v-for="(item, idx) in section.items"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors"
|
||||
:class="getItemClass(recentOffset + getFlatIndex(section.id, idx))"
|
||||
@click="selectHelpItem(section, item)"
|
||||
>
|
||||
<span class="text-white/90">{{ item.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- AI Assistant placeholder -->
|
||||
<div class="p-2 border-t border-white/10">
|
||||
<div class="px-3 py-2 text-xs font-medium text-white/50 uppercase tracking-wider">AI Assistant</div>
|
||||
<div class="px-3 py-3 rounded-lg bg-white/5 text-white/50 text-sm">
|
||||
Coming soon — ask questions about your node, apps, and Bitcoin.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import Fuse from 'fuse.js'
|
||||
import { useSpotlightStore } from '@/stores/spotlight'
|
||||
import { useCLIStore } from '@/stores/cli'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { helpTree, flattenForSearch, type SearchableItem } from '@/data/helpTree'
|
||||
|
||||
const router = useRouter()
|
||||
const spotlightStore = useSpotlightStore()
|
||||
const cliStore = useCLIStore()
|
||||
const appStore = useAppStore()
|
||||
const appLauncherStore = useAppLauncherStore()
|
||||
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
const panelRef = ref<HTMLElement | null>(null)
|
||||
const dragHandleRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const query = ref('')
|
||||
const isDragging = ref(false)
|
||||
const dragStart = ref<{ x: number; y: number; panelX: number; panelY: number } | null>(null)
|
||||
|
||||
const staticItems = flattenForSearch()
|
||||
|
||||
// Build dynamic app items from installed packages
|
||||
const dynamicAppItems = computed<SearchableItem[]>(() => {
|
||||
const pkgs = appStore.packages
|
||||
return Object.entries(pkgs).map(([id, pkg]) => ({
|
||||
id: `app-${id}`,
|
||||
label: pkg.manifest?.title || id,
|
||||
path: `__launch_app__:${id}`,
|
||||
type: 'action' as const,
|
||||
section: 'Installed Apps',
|
||||
}))
|
||||
})
|
||||
|
||||
const allSearchableItems = computed(() => [...staticItems, ...dynamicAppItems.value])
|
||||
|
||||
const fuse = computed(() => new Fuse(allSearchableItems.value, {
|
||||
keys: ['label', 'section'],
|
||||
threshold: 0.4,
|
||||
}))
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
const q = query.value.trim()
|
||||
if (!q) return []
|
||||
const results = fuse.value.search(q)
|
||||
return results.map((r) => r.item)
|
||||
})
|
||||
|
||||
const recentOffset = computed(() =>
|
||||
!query.value.trim() && spotlightStore.recentItems.length > 0 ? spotlightStore.recentItems.length : 0
|
||||
)
|
||||
|
||||
const selectableCount = computed(() => {
|
||||
if (query.value.trim()) return filteredItems.value.length
|
||||
return recentOffset.value + allSearchableItems.value.length
|
||||
})
|
||||
|
||||
const panelStyle = computed(() => {
|
||||
const pos = savedPosition.value
|
||||
if (!pos) return {}
|
||||
return {
|
||||
transform: `translate(${pos.x}px, ${pos.y}px)`,
|
||||
margin: 0,
|
||||
}
|
||||
})
|
||||
|
||||
const SAVED_POSITION_KEY = 'archipelago-spotlight-position'
|
||||
const savedPosition = ref<{ x: number; y: number } | null>(null)
|
||||
|
||||
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 (e) {
|
||||
if (import.meta.env.DEV) console.warn('Failed to load saved spotlight position', e)
|
||||
savedPosition.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function savePosition(x: number, y: number) {
|
||||
savedPosition.value = { x, y }
|
||||
try {
|
||||
localStorage.setItem(SAVED_POSITION_KEY, JSON.stringify({ x, y }))
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('Failed to save spotlight position', e)
|
||||
}
|
||||
}
|
||||
|
||||
function getFlatIndex(sectionId: string, itemIdx: number): number {
|
||||
let idx = 0
|
||||
for (const s of helpTree) {
|
||||
if (s.id === sectionId) return idx + itemIdx
|
||||
idx += s.items.length
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function getItemClass(index: number) {
|
||||
const selected = spotlightStore.selectedIndex
|
||||
return index === selected
|
||||
? 'bg-amber-500/20 text-amber-200'
|
||||
: 'hover:bg-white/10 text-white/90'
|
||||
}
|
||||
|
||||
function launchInstalledApp(appId: string) {
|
||||
const pkg = appStore.packages[appId]
|
||||
if (!pkg) return
|
||||
let lanAddress = pkg.installed?.['interface-addresses']?.main?.['lan-address']
|
||||
if (lanAddress && lanAddress.includes('localhost')) {
|
||||
lanAddress = lanAddress.replace('localhost', window.location.hostname)
|
||||
}
|
||||
if (lanAddress) {
|
||||
appLauncherStore.open({ url: lanAddress, title: pkg.manifest?.title || appId })
|
||||
} else {
|
||||
router.push(`/dashboard/apps/${appId}`).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function selectItem(item: SearchableItem) {
|
||||
spotlightStore.addRecentItem({
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
path: item.path,
|
||||
type: item.type,
|
||||
})
|
||||
spotlightStore.close()
|
||||
if (item.path?.startsWith('__launch_app__:')) {
|
||||
launchInstalledApp(item.path.replace('__launch_app__:', ''))
|
||||
} else if (item.path === '__cli__') {
|
||||
cliStore.open()
|
||||
} else if (item.path) {
|
||||
router.push(item.path)
|
||||
} else if (item.content) {
|
||||
spotlightStore.showHelpModal({ title: item.label, content: item.content, relatedPath: item.relatedPath })
|
||||
}
|
||||
}
|
||||
|
||||
function selectHelpItem(section: { id: string }, item: { id: string; label: string; path?: string; content?: string; relatedPath?: string }) {
|
||||
const type = section.id === 'navigate' ? 'navigate' : section.id === 'learn' ? 'learn' : 'action'
|
||||
spotlightStore.addRecentItem({
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
path: item.path,
|
||||
type,
|
||||
})
|
||||
spotlightStore.close()
|
||||
if (item.path?.startsWith('__launch_app__:')) {
|
||||
launchInstalledApp(item.path.replace('__launch_app__:', ''))
|
||||
} else if (item.path === '__cli__') {
|
||||
cliStore.open()
|
||||
} else if (item.path) {
|
||||
router.push(item.path)
|
||||
} else if (item.content) {
|
||||
spotlightStore.showHelpModal({ title: item.label, content: item.content, relatedPath: item.relatedPath })
|
||||
}
|
||||
}
|
||||
|
||||
function selectRecent(item: { id: string; label: string; path?: string; type: 'navigate' | 'learn' | 'action' | 'goal' }) {
|
||||
spotlightStore.close()
|
||||
if (item.path?.startsWith('__launch_app__:')) {
|
||||
launchInstalledApp(item.path.replace('__launch_app__:', ''))
|
||||
return
|
||||
}
|
||||
if (item.path === '__cli__') {
|
||||
cliStore.open()
|
||||
return
|
||||
}
|
||||
if (item.path) {
|
||||
router.push(item.path)
|
||||
return
|
||||
}
|
||||
if (item.type === 'learn') {
|
||||
for (const s of helpTree) {
|
||||
const helpItem = s.items.find((i) => i.id === item.id)
|
||||
if (helpItem?.content) {
|
||||
spotlightStore.showHelpModal({ title: helpItem.label, content: helpItem.content, relatedPath: helpItem.relatedPath })
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onInputKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
spotlightStore.close()
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
spotlightStore.setSelectedIndex(
|
||||
Math.min(spotlightStore.selectedIndex + 1, Math.max(0, selectableCount.value - 1))
|
||||
)
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
spotlightStore.setSelectedIndex(Math.max(spotlightStore.selectedIndex - 1, 0))
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const idx = spotlightStore.selectedIndex
|
||||
if (query.value.trim()) {
|
||||
const item = filteredItems.value[idx]
|
||||
if (item) selectItem(item)
|
||||
return
|
||||
}
|
||||
if (idx < recentOffset.value) {
|
||||
const item = spotlightStore.recentItems[idx]
|
||||
if (item) selectRecent(item)
|
||||
return
|
||||
}
|
||||
const helpIdx = idx - recentOffset.value
|
||||
let count = 0
|
||||
for (const s of helpTree) {
|
||||
for (const item of s.items) {
|
||||
if (count === helpIdx) {
|
||||
selectHelpItem(s, item)
|
||||
return
|
||||
}
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
watch(
|
||||
() => spotlightStore.isOpen,
|
||||
(open) => {
|
||||
if (open) {
|
||||
query.value = ''
|
||||
loadSavedPosition()
|
||||
nextTick(() => {
|
||||
inputRef.value?.focus()
|
||||
spotlightStore.setSelectedIndex(0)
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
[query, filteredItems],
|
||||
() => {
|
||||
spotlightStore.setSelectedIndex(0)
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
loadSavedPosition()
|
||||
window.addEventListener('mousemove', onMouseMove)
|
||||
window.addEventListener('mouseup', onMouseUp)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('mousemove', onMouseMove)
|
||||
window.removeEventListener('mouseup', onMouseUp)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.spotlight-enter-active,
|
||||
.spotlight-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.spotlight-enter-from,
|
||||
.spotlight-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
66
neode-ui/src/components/ToastStack.vue
Normal file
66
neode-ui/src/components/ToastStack.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fixed top-4 right-4 z-[9999] flex flex-col gap-2 pointer-events-none max-w-sm w-full">
|
||||
<TransitionGroup name="toast-stack">
|
||||
<div
|
||||
v-for="toast in toasts"
|
||||
:key="toast.id"
|
||||
class="toast-stack-item pointer-events-auto flex items-center gap-3 px-4 py-3 rounded-xl border cursor-pointer"
|
||||
:class="variantClass(toast.variant)"
|
||||
@click="dismiss(toast.id)"
|
||||
>
|
||||
<div class="w-5 h-5 shrink-0 flex items-center justify-center">
|
||||
<!-- Success -->
|
||||
<svg v-if="toast.variant === 'success'" class="w-5 h-5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<!-- Error -->
|
||||
<svg v-else-if="toast.variant === 'error'" class="w-5 h-5 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<!-- Info -->
|
||||
<svg v-else class="w-5 h-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm text-white/90 flex-1">{{ toast.message }}</span>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import type { ToastVariant } from '@/composables/useToast'
|
||||
|
||||
const { toasts, dismiss } = useToast()
|
||||
|
||||
function variantClass(variant: ToastVariant): string {
|
||||
switch (variant) {
|
||||
case 'success': return 'bg-black/70 border-green-500/30 backdrop-blur-md'
|
||||
case 'error': return 'bg-black/70 border-red-500/30 backdrop-blur-md'
|
||||
default: return 'bg-black/70 border-blue-500/30 backdrop-blur-md'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toast-stack-enter-active {
|
||||
transition: opacity 0.3s ease, transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.toast-stack-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
.toast-stack-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
.toast-stack-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(50%);
|
||||
}
|
||||
.toast-stack-move {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
117
neode-ui/src/components/__tests__/LineChart.test.ts
Normal file
117
neode-ui/src/components/__tests__/LineChart.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { shallowMount } from '@vue/test-utils'
|
||||
import LineChart from '../LineChart.vue'
|
||||
|
||||
// Mock canvas context
|
||||
const mockContext = {
|
||||
clearRect: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
closePath: vi.fn(),
|
||||
setLineDash: vi.fn(),
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
scale: vi.fn(),
|
||||
createLinearGradient: vi.fn().mockReturnValue({
|
||||
addColorStop: vi.fn(),
|
||||
}),
|
||||
canvas: { width: 600, height: 200 },
|
||||
strokeStyle: '',
|
||||
fillStyle: '',
|
||||
lineWidth: 0,
|
||||
font: '',
|
||||
textAlign: '',
|
||||
textBaseline: '',
|
||||
globalAlpha: 1,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
HTMLCanvasElement.prototype.getContext = vi.fn().mockReturnValue(mockContext)
|
||||
})
|
||||
|
||||
describe('LineChart', () => {
|
||||
const sampleDatasets = [
|
||||
{ label: 'CPU', data: [10, 20, 30, 40, 50], color: '#fb923c' },
|
||||
]
|
||||
|
||||
it('renders a canvas element', () => {
|
||||
const wrapper = shallowMount(LineChart, {
|
||||
props: { datasets: sampleDatasets },
|
||||
})
|
||||
expect(wrapper.find('canvas').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts datasets prop', () => {
|
||||
const wrapper = shallowMount(LineChart, {
|
||||
props: { datasets: sampleDatasets },
|
||||
})
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders with empty datasets', () => {
|
||||
const wrapper = shallowMount(LineChart, {
|
||||
props: { datasets: [] },
|
||||
})
|
||||
expect(wrapper.find('canvas').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders with multiple datasets', () => {
|
||||
const wrapper = shallowMount(LineChart, {
|
||||
props: {
|
||||
datasets: [
|
||||
{ label: 'CPU', data: [10, 20, 30], color: '#fb923c' },
|
||||
{ label: 'Memory', data: [50, 60, 70], color: '#4ade80' },
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts optional height and width props', () => {
|
||||
const wrapper = shallowMount(LineChart, {
|
||||
props: {
|
||||
datasets: sampleDatasets,
|
||||
height: 300,
|
||||
width: 600,
|
||||
},
|
||||
})
|
||||
const canvas = wrapper.find('canvas')
|
||||
expect(canvas.attributes('width')).toBe('600')
|
||||
expect(canvas.attributes('height')).toBe('300')
|
||||
})
|
||||
|
||||
it('uses default width of 400 and height of 180', () => {
|
||||
const wrapper = shallowMount(LineChart, {
|
||||
props: { datasets: sampleDatasets },
|
||||
})
|
||||
const canvas = wrapper.find('canvas')
|
||||
expect(canvas.attributes('width')).toBe('400')
|
||||
expect(canvas.attributes('height')).toBe('180')
|
||||
})
|
||||
|
||||
it('renders with dataset containing single data point', () => {
|
||||
const wrapper = shallowMount(LineChart, {
|
||||
props: {
|
||||
datasets: [{ label: 'Test', data: [42], color: '#3b82f6' }],
|
||||
},
|
||||
})
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts yMax and yLabel props', () => {
|
||||
const wrapper = shallowMount(LineChart, {
|
||||
props: {
|
||||
datasets: sampleDatasets,
|
||||
yMax: 100,
|
||||
yLabel: 'Percent',
|
||||
},
|
||||
})
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
103
neode-ui/src/components/__tests__/PWAInstallPrompt.test.ts
Normal file
103
neode-ui/src/components/__tests__/PWAInstallPrompt.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { shallowMount } from '@vue/test-utils'
|
||||
import PWAInstallPrompt from '../PWAInstallPrompt.vue'
|
||||
|
||||
describe('PWAInstallPrompt', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
sessionStorage.clear()
|
||||
// Mock matchMedia to return non-standalone
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
})
|
||||
})
|
||||
|
||||
it('renders without errors', () => {
|
||||
const wrapper = shallowMount(PWAInstallPrompt, {
|
||||
global: { stubs: { Teleport: true, Transition: true } },
|
||||
})
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not show prompt initially', () => {
|
||||
const wrapper = shallowMount(PWAInstallPrompt, {
|
||||
global: { stubs: { Teleport: true, Transition: true } },
|
||||
})
|
||||
expect(wrapper.text()).not.toContain('Install Archipelago')
|
||||
})
|
||||
|
||||
it('shows prompt after beforeinstallprompt event', async () => {
|
||||
const wrapper = shallowMount(PWAInstallPrompt, {
|
||||
global: { stubs: { Teleport: true, Transition: true } },
|
||||
})
|
||||
|
||||
// Fire the beforeinstallprompt event
|
||||
const event = new Event('beforeinstallprompt')
|
||||
Object.defineProperty(event, 'preventDefault', { value: vi.fn() })
|
||||
window.dispatchEvent(event)
|
||||
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.text()).toContain('Install Archipelago')
|
||||
})
|
||||
|
||||
it('hides prompt when dismissed', async () => {
|
||||
const wrapper = shallowMount(PWAInstallPrompt, {
|
||||
global: { stubs: { Teleport: true, Transition: true } },
|
||||
})
|
||||
|
||||
// Show prompt
|
||||
const event = new Event('beforeinstallprompt')
|
||||
Object.defineProperty(event, 'preventDefault', { value: vi.fn() })
|
||||
window.dispatchEvent(event)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
// Click dismiss button
|
||||
const dismissBtn = wrapper.findAll('button').find(b => b.text().includes('Not now'))
|
||||
expect(dismissBtn).toBeDefined()
|
||||
await dismissBtn!.trigger('click')
|
||||
expect(sessionStorage.getItem('archipelago_pwa_install_dismissed')).toBe('1')
|
||||
})
|
||||
|
||||
it('does not show if already dismissed this session', async () => {
|
||||
sessionStorage.setItem('archipelago_pwa_install_dismissed', '1')
|
||||
const wrapper = shallowMount(PWAInstallPrompt, {
|
||||
global: { stubs: { Teleport: true, Transition: true } },
|
||||
})
|
||||
|
||||
// Fire beforeinstallprompt — should not show
|
||||
const event = new Event('beforeinstallprompt')
|
||||
Object.defineProperty(event, 'preventDefault', { value: vi.fn() })
|
||||
window.dispatchEvent(event)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).not.toContain('Install Archipelago')
|
||||
})
|
||||
|
||||
it('does not show in standalone mode', async () => {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockReturnValue({ matches: true }),
|
||||
})
|
||||
|
||||
const wrapper = shallowMount(PWAInstallPrompt, {
|
||||
global: { stubs: { Teleport: true, Transition: true } },
|
||||
})
|
||||
|
||||
const event = new Event('beforeinstallprompt')
|
||||
Object.defineProperty(event, 'preventDefault', { value: vi.fn() })
|
||||
window.dispatchEvent(event)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).not.toContain('Install Archipelago')
|
||||
})
|
||||
})
|
||||
99
neode-ui/src/components/cloud/CloudToolbar.vue
Normal file
99
neode-ui/src/components/cloud/CloudToolbar.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<div class="cloud-toolbar">
|
||||
<!-- Breadcrumbs -->
|
||||
<nav class="cloud-breadcrumbs">
|
||||
<button
|
||||
v-for="(crumb, i) in breadcrumbs"
|
||||
:key="crumb.path"
|
||||
class="cloud-breadcrumb-item"
|
||||
:class="{ 'cloud-breadcrumb-active': i === breadcrumbs.length - 1 }"
|
||||
@click="i < breadcrumbs.length - 1 && $emit('navigate', crumb.path)"
|
||||
>
|
||||
<svg v-if="i === 0" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
<span v-else>{{ crumb.name }}</span>
|
||||
<svg v-if="i < breadcrumbs.length - 1" class="w-3 h-3 text-white/30 mx-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- View toggle -->
|
||||
<div class="cloud-view-toggle">
|
||||
<button
|
||||
class="cloud-view-toggle-btn"
|
||||
:class="{ 'cloud-view-toggle-active': viewMode === 'grid' }"
|
||||
title="Grid view"
|
||||
@click="$emit('update:viewMode', 'grid')"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="cloud-view-toggle-btn"
|
||||
:class="{ 'cloud-view-toggle-active': viewMode === 'list' }"
|
||||
title="List view"
|
||||
@click="$emit('update:viewMode', 'list')"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button class="glass-button cloud-toolbar-btn" title="Upload file" @click="triggerUpload">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
<span class="hidden md:inline">Upload</span>
|
||||
</button>
|
||||
<button class="glass-button cloud-toolbar-btn" title="Refresh" @click="$emit('refresh')">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
class="hidden"
|
||||
multiple
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
breadcrumbs: { name: string; path: string }[]
|
||||
viewMode: 'list' | 'grid'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
navigate: [path: string]
|
||||
refresh: []
|
||||
upload: [files: File[]]
|
||||
'update:viewMode': [mode: 'list' | 'grid']
|
||||
}>()
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function triggerUpload() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
function handleFileSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
if (input.files && input.files.length > 0) {
|
||||
emit('upload', Array.from(input.files))
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
126
neode-ui/src/components/cloud/FileCard.vue
Normal file
126
neode-ui/src/components/cloud/FileCard.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<button
|
||||
class="cloud-file-item group"
|
||||
data-controller-container
|
||||
tabindex="0"
|
||||
@click="handleClick"
|
||||
>
|
||||
<!-- Thumbnail / Icon -->
|
||||
<div class="cloud-file-item-thumb">
|
||||
<img
|
||||
v-if="isImage && thumbnailUrl && !imgFailed"
|
||||
:src="thumbnailUrl"
|
||||
:alt="item.name"
|
||||
class="w-full h-full object-cover rounded-[6px] transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
@error="imgFailed = true"
|
||||
/>
|
||||
<div v-else class="w-full h-full rounded-[6px] flex items-center justify-center bg-white/8">
|
||||
<svg class="w-5 h-5" :class="iconColor" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
v-for="(d, i) in iconPaths"
|
||||
:key="i"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
:d="d"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="min-w-0 flex-1 py-0.5">
|
||||
<p class="text-sm font-semibold truncate text-white/90">{{ item.name }}</p>
|
||||
<p class="text-xs mt-0.5 text-white/40">
|
||||
<span v-if="!item.isDir">{{ formatSize(item.size) }}</span>
|
||||
<span v-if="!item.isDir"> · </span>
|
||||
<span>{{ formatDate(item.modified) }}</span>
|
||||
</p>
|
||||
<!-- Type badge -->
|
||||
<div class="flex items-center gap-1.5 mt-1.5">
|
||||
<span class="cloud-file-badge" :class="badgeClass">
|
||||
{{ badgeLabel }}
|
||||
</span>
|
||||
<span v-if="item.extension && !item.isDir" class="cloud-file-badge bg-white/8 text-white/50">
|
||||
.{{ item.extension }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="cloud-file-item-actions" @click.stop>
|
||||
<button
|
||||
class="cloud-file-action-btn cloud-file-action-share"
|
||||
title="Share with peers"
|
||||
@click.stop="$emit('share', item.path, item.name, item.isDir)"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
||||
</svg>
|
||||
</button>
|
||||
<a
|
||||
v-if="!item.isDir"
|
||||
:href="downloadHref"
|
||||
download
|
||||
class="cloud-file-action-btn"
|
||||
title="Download"
|
||||
@click.stop
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
</a>
|
||||
<button
|
||||
v-if="!item.isDir"
|
||||
class="cloud-file-action-btn cloud-file-action-delete"
|
||||
title="Delete"
|
||||
@click.stop="$emit('delete', item.path)"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<svg v-if="item.isDir" class="w-4 h-4 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { FileBrowserItem } from '@/api/filebrowser-client'
|
||||
import { useCloudStore } from '@/stores/cloud'
|
||||
import { useFileType, formatSize, formatDate } from '@/composables/useFileType'
|
||||
|
||||
const props = defineProps<{
|
||||
item: FileBrowserItem
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
navigate: [path: string]
|
||||
delete: [path: string]
|
||||
share: [path: string, name: string, isDir: boolean]
|
||||
}>()
|
||||
|
||||
const cloudStore = useCloudStore()
|
||||
const imgFailed = ref(false)
|
||||
|
||||
const ext = computed(() => props.item.extension)
|
||||
const isDir = computed(() => props.item.isDir)
|
||||
const { isImage, iconPaths, iconColor, badgeLabel, badgeClass } = useFileType(ext, isDir)
|
||||
|
||||
const thumbnailUrl = computed(() => {
|
||||
if (!isImage.value || imgFailed.value) return null
|
||||
return cloudStore.downloadUrl(props.item.path)
|
||||
})
|
||||
|
||||
const downloadHref = computed(() => cloudStore.downloadUrl(props.item.path))
|
||||
|
||||
function handleClick() {
|
||||
if (props.item.isDir) {
|
||||
emit('navigate', props.item.path)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
176
neode-ui/src/components/cloud/FileCardGrid.vue
Normal file
176
neode-ui/src/components/cloud/FileCardGrid.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<button
|
||||
class="cloud-grid-card group"
|
||||
data-controller-container
|
||||
tabindex="0"
|
||||
@click="handleClick"
|
||||
>
|
||||
<!-- Cover / Thumbnail area -->
|
||||
<div class="cloud-grid-card-cover" :class="aspectClass">
|
||||
<!-- Image thumbnail -->
|
||||
<img
|
||||
v-if="isImage && thumbnailUrl && !imgFailed"
|
||||
:src="thumbnailUrl"
|
||||
:alt="item.name"
|
||||
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
|
||||
loading="lazy"
|
||||
@error="imgFailed = true"
|
||||
/>
|
||||
<!-- Video thumbnail (try to show, fallback to icon) -->
|
||||
<img
|
||||
v-else-if="isVideo && thumbnailUrl && !imgFailed"
|
||||
:src="thumbnailUrl"
|
||||
:alt="item.name"
|
||||
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
|
||||
loading="lazy"
|
||||
@error="imgFailed = true"
|
||||
/>
|
||||
<!-- Icon fallback -->
|
||||
<div v-else class="w-full h-full flex items-center justify-center" :class="coverBg">
|
||||
<svg class="w-10 h-10" :class="iconColor" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
v-for="(d, i) in iconPaths"
|
||||
:key="i"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
:d="d"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Gradient overlay -->
|
||||
<div class="cloud-grid-card-gradient"></div>
|
||||
|
||||
<!-- Play button overlay for audio/video -->
|
||||
<div
|
||||
v-if="isAudio || isVideo"
|
||||
class="cloud-grid-card-play"
|
||||
:class="{ 'cloud-grid-card-play-active': isCurrentlyPlaying }"
|
||||
@click.stop="emit('play', item.path, item.name)"
|
||||
>
|
||||
<span class="cloud-grid-card-play-btn">
|
||||
<svg v-if="!isCurrentlyPlaying" class="w-8 h-8 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7L8 5z" />
|
||||
</svg>
|
||||
<svg v-else class="w-8 h-8 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Info overlay at bottom -->
|
||||
<div class="cloud-grid-card-info">
|
||||
<p class="text-xs font-semibold text-white/90 leading-tight truncate">
|
||||
{{ item.name }}
|
||||
</p>
|
||||
<div class="flex items-center gap-1 mt-0.5">
|
||||
<span v-if="!item.isDir" class="text-xs text-white/40">{{ formatSize(item.size) }}</span>
|
||||
<span v-if="!item.isDir" class="text-xs text-white/40">·</span>
|
||||
<span class="text-xs text-white/40">{{ formatDate(item.modified) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Badge at top-right -->
|
||||
<div class="cloud-grid-card-badges">
|
||||
<span class="cloud-grid-card-badge" :class="badgeClass">
|
||||
{{ badgeLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Actions overlay at top-left (visible on hover) -->
|
||||
<div class="cloud-grid-card-actions" @click.stop>
|
||||
<button
|
||||
class="cloud-file-action-btn cloud-file-action-share"
|
||||
title="Share with peers"
|
||||
@click.stop="emit('share', item.path, item.name, item.isDir)"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
||||
</svg>
|
||||
</button>
|
||||
<a
|
||||
v-if="!item.isDir"
|
||||
:href="downloadHref"
|
||||
download
|
||||
class="cloud-file-action-btn"
|
||||
title="Download"
|
||||
@click.stop
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
</a>
|
||||
<button
|
||||
v-if="!item.isDir"
|
||||
class="cloud-file-action-btn cloud-file-action-delete"
|
||||
title="Delete"
|
||||
@click.stop="emit('delete', item.path)"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { FileBrowserItem } from '@/api/filebrowser-client'
|
||||
import { useCloudStore } from '@/stores/cloud'
|
||||
import { useFileType, formatSize, formatDate } from '@/composables/useFileType'
|
||||
import { useAudioPlayer } from '@/composables/useAudioPlayer'
|
||||
|
||||
const props = defineProps<{
|
||||
item: FileBrowserItem
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
navigate: [path: string]
|
||||
delete: [path: string]
|
||||
play: [path: string, name: string]
|
||||
share: [path: string, name: string, isDir: boolean]
|
||||
}>()
|
||||
|
||||
const cloudStore = useCloudStore()
|
||||
const imgFailed = ref(false)
|
||||
|
||||
const ext = computed(() => props.item.extension)
|
||||
const isDir = computed(() => props.item.isDir)
|
||||
const { category, isImage, isAudio, isVideo, iconPaths, iconColor, badgeLabel, badgeClass } = useFileType(ext, isDir)
|
||||
|
||||
const thumbnailUrl = computed(() => {
|
||||
if (imgFailed.value) return null
|
||||
if (isImage.value || isVideo.value) return cloudStore.downloadUrl(props.item.path)
|
||||
return null
|
||||
})
|
||||
|
||||
const downloadHref = computed(() => cloudStore.downloadUrl(props.item.path))
|
||||
const { playing: audioPlaying, currentSrc } = useAudioPlayer()
|
||||
const isCurrentlyPlaying = computed(() => audioPlaying.value && currentSrc.value === downloadHref.value)
|
||||
|
||||
const aspectClass = computed(() => {
|
||||
if (isImage.value || isVideo.value) return 'aspect-square'
|
||||
if (category.value === 'document' || category.value === 'folder') return 'aspect-[4/3]'
|
||||
return 'aspect-square'
|
||||
})
|
||||
|
||||
const coverBg = computed(() => {
|
||||
if (props.item.isDir) return 'bg-amber-500/10'
|
||||
if (isAudio.value) return 'bg-orange-500/10'
|
||||
if (isVideo.value) return 'bg-purple-500/10'
|
||||
if (isImage.value) return 'bg-blue-500/10'
|
||||
if (category.value === 'document') return 'bg-green-500/10'
|
||||
if (category.value === 'spreadsheet') return 'bg-emerald-500/10'
|
||||
if (category.value === 'archive') return 'bg-yellow-500/10'
|
||||
return 'bg-white/5'
|
||||
})
|
||||
|
||||
function handleClick() {
|
||||
if (props.item.isDir) {
|
||||
emit('navigate', props.item.path)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
80
neode-ui/src/components/cloud/FileGrid.vue
Normal file
80
neode-ui/src/components/cloud/FileGrid.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<div class="flex-1 min-h-0 overflow-y-auto">
|
||||
<!-- Loading skeleton -->
|
||||
<div v-if="loading" :class="viewMode === 'grid' ? 'cloud-card-grid' : 'cloud-file-list'">
|
||||
<div
|
||||
v-for="i in 6"
|
||||
:key="i"
|
||||
:class="viewMode === 'grid' ? 'cloud-grid-card-skeleton' : 'cloud-file-item cloud-file-item-skeleton'"
|
||||
>
|
||||
<template v-if="viewMode === 'grid'">
|
||||
<div class="aspect-square rounded-[10px] bg-white/8 animate-pulse"></div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="cloud-file-item-thumb">
|
||||
<div class="w-full h-full rounded-[6px] bg-white/8 animate-pulse"></div>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 py-0.5">
|
||||
<div class="h-4 w-32 rounded bg-white/8 animate-pulse mb-1.5"></div>
|
||||
<div class="h-3 w-20 rounded bg-white/5 animate-pulse"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else-if="items.length === 0" class="flex flex-col items-center justify-center py-16 text-center">
|
||||
<svg class="w-16 h-16 text-white/10 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
<p class="text-white/50 text-sm">This folder is empty</p>
|
||||
<p class="text-white/30 text-xs mt-1">Upload files to get started</p>
|
||||
</div>
|
||||
|
||||
<!-- Grid view -->
|
||||
<div v-else-if="viewMode === 'grid'" class="cloud-card-grid">
|
||||
<FileCardGrid
|
||||
v-for="item in items"
|
||||
:key="item.path"
|
||||
:item="item"
|
||||
@navigate="$emit('navigate', $event)"
|
||||
@delete="$emit('delete', $event)"
|
||||
@play="(path, name) => $emit('play', path, name)"
|
||||
@share="(path, name, isDir) => $emit('share', path, name, isDir)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- List view -->
|
||||
<div v-else class="cloud-file-list">
|
||||
<FileCard
|
||||
v-for="item in items"
|
||||
:key="item.path"
|
||||
:item="item"
|
||||
@navigate="$emit('navigate', $event)"
|
||||
@delete="$emit('delete', $event)"
|
||||
@share="(path, name, isDir) => $emit('share', path, name, isDir)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FileBrowserItem } from '@/api/filebrowser-client'
|
||||
import FileCard from './FileCard.vue'
|
||||
import FileCardGrid from './FileCardGrid.vue'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
items: FileBrowserItem[]
|
||||
loading: boolean
|
||||
viewMode?: 'list' | 'grid'
|
||||
}>(), {
|
||||
viewMode: 'grid',
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
navigate: [path: string]
|
||||
delete: [path: string]
|
||||
play: [path: string, name: string]
|
||||
share: [path: string, name: string, isDir: boolean]
|
||||
}>()
|
||||
</script>
|
||||
257
neode-ui/src/components/cloud/ShareModal.vue
Normal file
257
neode-ui/src/components/cloud/ShareModal.vue
Normal file
@@ -0,0 +1,257 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="share-modal-backdrop" @click.self="$emit('close')">
|
||||
<div class="share-modal glass-card">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-9 h-9 rounded-lg bg-orange-500/15 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-white">Share with Peers</h3>
|
||||
<p class="text-xs text-white/50 truncate max-w-[200px]">{{ filename }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="share-modal-close" @click="$emit('close')">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Share Toggle -->
|
||||
<div class="share-modal-row">
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-white/90">Share this {{ isDir ? 'folder' : 'file' }}</p>
|
||||
<p class="text-xs text-white/50 mt-0.5">Make visible to connected peers</p>
|
||||
</div>
|
||||
<label class="share-toggle">
|
||||
<input type="checkbox" v-model="shared" />
|
||||
<span class="share-toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Access Type (only when shared) -->
|
||||
<div v-if="shared" class="mt-4 space-y-3">
|
||||
<p class="text-xs font-medium text-white/60 uppercase tracking-wider">Access Type</p>
|
||||
<div class="share-access-options">
|
||||
<button
|
||||
class="share-access-option"
|
||||
:class="{ 'share-access-option-active': accessType === 'free' }"
|
||||
@click="accessType = 'free'"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span class="text-sm font-medium">Free</span>
|
||||
<span class="text-xs text-white/40">Open access</span>
|
||||
</button>
|
||||
<button
|
||||
class="share-access-option"
|
||||
:class="{ 'share-access-option-active': accessType === 'peers_only' }"
|
||||
@click="accessType = 'peers_only'"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
<span class="text-sm font-medium">Peers Only</span>
|
||||
<span class="text-xs text-white/40">Authenticated</span>
|
||||
</button>
|
||||
<button
|
||||
class="share-access-option"
|
||||
:class="{ 'share-access-option-active': accessType === 'paid' }"
|
||||
@click="accessType = 'paid'"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span class="text-sm font-medium">Paid</span>
|
||||
<span class="text-xs text-white/40">Earn sats</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Price Input (only for paid) -->
|
||||
<div v-if="accessType === 'paid'" class="share-price-input-wrap">
|
||||
<div class="share-price-icon">
|
||||
<svg class="w-4 h-4 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
v-model.number="priceSats"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1000000"
|
||||
placeholder="Price in sats"
|
||||
class="share-price-input"
|
||||
/>
|
||||
<span class="share-price-unit">sats</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status messages -->
|
||||
<div v-if="saving" class="share-modal-status mt-4">
|
||||
<div class="w-4 h-4 border-2 border-white/20 border-t-white/80 rounded-full animate-spin"></div>
|
||||
<span class="text-sm text-white/60">Saving...</span>
|
||||
</div>
|
||||
<div v-if="errorMsg" class="share-modal-error mt-4">
|
||||
<span class="text-sm text-red-400">{{ errorMsg }}</span>
|
||||
</div>
|
||||
<div v-if="successMsg" class="share-modal-success mt-4">
|
||||
<svg class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span class="text-sm text-green-400">{{ successMsg }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Action -->
|
||||
<div class="flex justify-end gap-3 mt-5">
|
||||
<button class="glass-button px-4 py-2 rounded-lg text-sm" @click="$emit('close')">Cancel</button>
|
||||
<button
|
||||
class="glass-button px-5 py-2 rounded-lg text-sm font-medium share-modal-save"
|
||||
:disabled="saving || (shared && accessType === 'paid' && (!priceSats || priceSats < 1))"
|
||||
@click="save"
|
||||
>
|
||||
{{ shared ? 'Share' : 'Stop Sharing' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
const props = defineProps<{
|
||||
filename: string
|
||||
filepath: string
|
||||
isDir: boolean
|
||||
existingItemId?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const shared = ref(false)
|
||||
const accessType = ref<'free' | 'peers_only' | 'paid'>('free')
|
||||
const priceSats = ref<number>(100)
|
||||
const saving = ref(false)
|
||||
const errorMsg = ref<string | null>(null)
|
||||
const successMsg = ref<string | null>(null)
|
||||
|
||||
// If we have an existing item, load its state
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await rpcClient.call<{ items: Array<{
|
||||
id: string
|
||||
filename: string
|
||||
access: { free?: unknown; peersonly?: unknown; paid?: { price_sats: number } } | string
|
||||
availability: string | { allpeers?: unknown; nobody?: unknown }
|
||||
}> }>({ method: 'content.list-mine' })
|
||||
const match = res.items.find(
|
||||
(i) => i.filename === props.filename || i.filename === props.filepath
|
||||
)
|
||||
if (match) {
|
||||
shared.value = true
|
||||
const access = match.access
|
||||
if (typeof access === 'string') {
|
||||
if (access === 'free') accessType.value = 'free'
|
||||
else if (access === 'peersonly') accessType.value = 'peers_only'
|
||||
} else if (access && typeof access === 'object') {
|
||||
if ('paid' in access && access.paid) {
|
||||
accessType.value = 'paid'
|
||||
priceSats.value = access.paid.price_sats || 100
|
||||
} else if ('peersonly' in access) {
|
||||
accessType.value = 'peers_only'
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('Not shared yet, defaults are fine', e)
|
||||
}
|
||||
})
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
errorMsg.value = null
|
||||
successMsg.value = null
|
||||
|
||||
try {
|
||||
if (!shared.value) {
|
||||
// Find and remove from catalog
|
||||
const res = await rpcClient.call<{ items: Array<{ id: string; filename: string }> }>({
|
||||
method: 'content.list-mine',
|
||||
})
|
||||
const match = res.items.find(
|
||||
(i) => i.filename === props.filename || i.filename === props.filepath
|
||||
)
|
||||
if (match) {
|
||||
await rpcClient.call({ method: 'content.remove', params: { id: match.id } })
|
||||
}
|
||||
successMsg.value = 'Sharing disabled'
|
||||
} else {
|
||||
// Check if already in catalog
|
||||
const res = await rpcClient.call<{ items: Array<{ id: string; filename: string }> }>({
|
||||
method: 'content.list-mine',
|
||||
})
|
||||
let itemId = res.items.find(
|
||||
(i) => i.filename === props.filename || i.filename === props.filepath
|
||||
)?.id
|
||||
|
||||
// Add if not in catalog
|
||||
if (!itemId) {
|
||||
const ext = props.filename.split('.').pop()?.toLowerCase() || ''
|
||||
const mimeMap: Record<string, string> = {
|
||||
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
|
||||
webp: 'image/webp', mp4: 'video/mp4', webm: 'video/webm', mkv: 'video/x-matroska',
|
||||
mp3: 'audio/mpeg', flac: 'audio/flac', ogg: 'audio/ogg', wav: 'audio/wav',
|
||||
pdf: 'application/pdf', zip: 'application/zip', txt: 'text/plain',
|
||||
}
|
||||
const addRes = await rpcClient.call<{ item: { id: string } }>({
|
||||
method: 'content.add',
|
||||
params: {
|
||||
filename: props.filepath || props.filename,
|
||||
mime_type: mimeMap[ext] || 'application/octet-stream',
|
||||
description: '',
|
||||
},
|
||||
})
|
||||
itemId = addRes.item.id
|
||||
}
|
||||
|
||||
// Set pricing
|
||||
const pricingParams: Record<string, unknown> = { id: itemId, access: accessType.value }
|
||||
if (accessType.value === 'paid') {
|
||||
pricingParams.price_sats = priceSats.value
|
||||
}
|
||||
await rpcClient.call({ method: 'content.set-pricing', params: pricingParams })
|
||||
|
||||
// Set availability to all peers
|
||||
await rpcClient.call({
|
||||
method: 'content.set-availability',
|
||||
params: { id: itemId, availability: 'all_peers' },
|
||||
})
|
||||
|
||||
const label =
|
||||
accessType.value === 'paid'
|
||||
? `Shared for ${priceSats.value} sats`
|
||||
: accessType.value === 'peers_only'
|
||||
? 'Shared with peers'
|
||||
: 'Shared (free)'
|
||||
successMsg.value = label
|
||||
}
|
||||
|
||||
setTimeout(() => emit('saved'), 800)
|
||||
} catch (e) {
|
||||
errorMsg.value = e instanceof Error ? e.message : 'Failed to update sharing'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
176
neode-ui/src/components/federation/NetworkMap.vue
Normal file
176
neode-ui/src/components/federation/NetworkMap.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<div ref="containerRef" class="network-map-container">
|
||||
<svg ref="svgRef" class="w-full h-full"></svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import * as d3 from 'd3'
|
||||
|
||||
interface MapNode {
|
||||
did: string
|
||||
label: string
|
||||
trust_level: 'trusted' | 'observer' | 'untrusted'
|
||||
online: boolean
|
||||
app_count: number
|
||||
is_self: boolean
|
||||
}
|
||||
|
||||
interface MapLink {
|
||||
source: string
|
||||
target: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
nodes: MapNode[]
|
||||
links: MapLink[]
|
||||
}>()
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const svgRef = ref<SVGSVGElement>()
|
||||
|
||||
type SimNode = MapNode & d3.SimulationNodeDatum
|
||||
type SimLink = d3.SimulationLinkDatum<SimNode> & { source: string | SimNode; target: string | SimNode }
|
||||
|
||||
let simulation: d3.Simulation<SimNode, SimLink> | null = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
function trustColor(level: string): string {
|
||||
switch (level) {
|
||||
case 'trusted': return '#4ade80'
|
||||
case 'observer': return '#fb923c'
|
||||
case 'untrusted': return '#ef4444'
|
||||
default: return '#9ca3af'
|
||||
}
|
||||
}
|
||||
|
||||
function nodeRadius(n: MapNode): number {
|
||||
return n.is_self ? 18 : Math.max(10, Math.min(16, 8 + n.app_count * 0.5))
|
||||
}
|
||||
|
||||
function render() {
|
||||
const svg = d3.select(svgRef.value!)
|
||||
svg.selectAll('*').remove()
|
||||
|
||||
const container = containerRef.value!
|
||||
const width = container.clientWidth
|
||||
const height = container.clientHeight
|
||||
|
||||
svg.attr('viewBox', `0 0 ${width} ${height}`)
|
||||
|
||||
const simNodes: SimNode[] = props.nodes.map(n => ({ ...n }))
|
||||
const simLinks: SimLink[] = props.links.map(l => ({ ...l }))
|
||||
|
||||
// Center the self-node
|
||||
const selfNode = simNodes.find(n => n.is_self)
|
||||
if (selfNode) {
|
||||
selfNode.fx = width / 2
|
||||
selfNode.fy = height / 2
|
||||
}
|
||||
|
||||
simulation = d3.forceSimulation(simNodes)
|
||||
.force('link', d3.forceLink<SimNode, SimLink>(simLinks).id(d => d.did).distance(120))
|
||||
.force('charge', d3.forceManyBody().strength(-300))
|
||||
.force('center', d3.forceCenter(width / 2, height / 2))
|
||||
.force('collision', d3.forceCollide<SimNode>().radius(d => nodeRadius(d) + 5))
|
||||
|
||||
const g = svg.append('g')
|
||||
|
||||
// Links
|
||||
const link = g.append('g')
|
||||
.selectAll('line')
|
||||
.data(simLinks)
|
||||
.join('line')
|
||||
.attr('stroke', (d: SimLink) => {
|
||||
const src = typeof d.source === 'object' ? d.source : simNodes.find(n => n.did === d.source)
|
||||
const tgt = typeof d.target === 'object' ? d.target : simNodes.find(n => n.did === d.target)
|
||||
return (src as MapNode)?.online && (tgt as MapNode)?.online ? '#4ade8060' : '#6b728050'
|
||||
})
|
||||
.attr('stroke-width', 2)
|
||||
.attr('stroke-dasharray', (d: SimLink) => {
|
||||
const src = typeof d.source === 'object' ? d.source : simNodes.find(n => n.did === d.source)
|
||||
const tgt = typeof d.target === 'object' ? d.target : simNodes.find(n => n.did === d.target)
|
||||
return (src as MapNode)?.online && (tgt as MapNode)?.online ? 'none' : '6 4'
|
||||
})
|
||||
|
||||
// Node groups
|
||||
const node = g.append('g')
|
||||
.selectAll<SVGGElement, SimNode>('g')
|
||||
.data(simNodes)
|
||||
.join('g')
|
||||
.attr('cursor', 'pointer')
|
||||
.call(d3.drag<SVGGElement, SimNode>()
|
||||
.on('start', (event, d) => {
|
||||
if (!event.active) simulation!.alphaTarget(0.3).restart()
|
||||
d.fx = d.x
|
||||
d.fy = d.y
|
||||
})
|
||||
.on('drag', (event, d) => {
|
||||
d.fx = event.x
|
||||
d.fy = event.y
|
||||
})
|
||||
.on('end', (event, d) => {
|
||||
if (!event.active) simulation!.alphaTarget(0)
|
||||
if (!d.is_self) { d.fx = null; d.fy = null }
|
||||
})
|
||||
)
|
||||
|
||||
// Node circles
|
||||
node.append('circle')
|
||||
.attr('r', d => nodeRadius(d))
|
||||
.attr('fill', d => trustColor(d.trust_level))
|
||||
.attr('fill-opacity', d => d.online ? 0.8 : 0.3)
|
||||
.attr('stroke', d => d.is_self ? '#fb923c' : trustColor(d.trust_level))
|
||||
.attr('stroke-width', d => d.is_self ? 3 : 1.5)
|
||||
.attr('stroke-opacity', d => d.online ? 1 : 0.4)
|
||||
|
||||
// Node labels
|
||||
node.append('text')
|
||||
.text(d => d.label)
|
||||
.attr('dy', d => nodeRadius(d) + 14)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('fill', 'rgba(255,255,255,0.7)')
|
||||
.attr('font-size', '11px')
|
||||
.attr('font-family', "'Avenir Next', sans-serif")
|
||||
|
||||
// Tooltip
|
||||
node.append('title')
|
||||
.text(d => `${d.did}\nApps: ${d.app_count}\n${d.online ? 'Online' : 'Offline'}`)
|
||||
|
||||
simulation.on('tick', () => {
|
||||
link
|
||||
.attr('x1', d => (d.source as SimNode).x!)
|
||||
.attr('y1', d => (d.source as SimNode).y!)
|
||||
.attr('x2', d => (d.target as SimNode).x!)
|
||||
.attr('y2', d => (d.target as SimNode).y!)
|
||||
|
||||
node.attr('transform', d => `translate(${d.x},${d.y})`)
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
render()
|
||||
resizeObserver = new ResizeObserver(() => render())
|
||||
if (containerRef.value) resizeObserver.observe(containerRef.value)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
simulation?.stop()
|
||||
resizeObserver?.disconnect()
|
||||
})
|
||||
|
||||
watch(() => [props.nodes, props.links], () => render(), { deep: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.network-map-container {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(24px);
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22);
|
||||
min-height: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
138
neode-ui/src/composables/__tests__/useAudioPlayer.test.ts
Normal file
138
neode-ui/src/composables/__tests__/useAudioPlayer.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { useAudioPlayer } from '../useAudioPlayer'
|
||||
|
||||
// Mock HTMLAudioElement
|
||||
class MockAudio {
|
||||
src = ''
|
||||
currentTime = 0
|
||||
duration = 120
|
||||
paused = true
|
||||
private listeners: Record<string, Array<() => void>> = {}
|
||||
|
||||
addEventListener(event: string, handler: () => void) {
|
||||
if (!this.listeners[event]) this.listeners[event] = []
|
||||
this.listeners[event].push(handler)
|
||||
}
|
||||
|
||||
removeEventListener() {
|
||||
// no-op for tests
|
||||
}
|
||||
|
||||
play() {
|
||||
this.paused = false
|
||||
this.emit('play')
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
pause() {
|
||||
this.paused = true
|
||||
this.emit('pause')
|
||||
}
|
||||
|
||||
private emit(event: string) {
|
||||
const handlers = this.listeners[event] || []
|
||||
handlers.forEach(h => h())
|
||||
}
|
||||
|
||||
// Helper to simulate events in tests
|
||||
simulateEvent(event: string) {
|
||||
this.emit(event)
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal('Audio', MockAudio)
|
||||
|
||||
describe('useAudioPlayer', () => {
|
||||
beforeEach(() => {
|
||||
// Reset singleton state by stopping any active playback
|
||||
const player = useAudioPlayer()
|
||||
player.stop()
|
||||
})
|
||||
|
||||
it('returns all expected properties', () => {
|
||||
const player = useAudioPlayer()
|
||||
expect(player.play).toBeTypeOf('function')
|
||||
expect(player.pause).toBeTypeOf('function')
|
||||
expect(player.seek).toBeTypeOf('function')
|
||||
expect(player.stop).toBeTypeOf('function')
|
||||
expect(player.playing).toBeDefined()
|
||||
expect(player.currentName).toBeDefined()
|
||||
expect(player.currentTime).toBeDefined()
|
||||
expect(player.duration).toBeDefined()
|
||||
expect(player.progress).toBeDefined()
|
||||
expect(player.currentSrc).toBeDefined()
|
||||
expect(player.error).toBeDefined()
|
||||
})
|
||||
|
||||
it('starts in stopped state', () => {
|
||||
const player = useAudioPlayer()
|
||||
expect(player.playing.value).toBe(false)
|
||||
expect(player.currentSrc.value).toBeNull()
|
||||
expect(player.currentName.value).toBe('')
|
||||
})
|
||||
|
||||
it('play sets playing state and current source', () => {
|
||||
const player = useAudioPlayer()
|
||||
player.play('/audio/test.mp3', 'Test Track')
|
||||
expect(player.playing.value).toBe(true)
|
||||
expect(player.currentSrc.value).toBe('/audio/test.mp3')
|
||||
expect(player.currentName.value).toBe('Test Track')
|
||||
})
|
||||
|
||||
it('play toggles pause when same source is playing', () => {
|
||||
const player = useAudioPlayer()
|
||||
player.play('/audio/test.mp3', 'Test')
|
||||
expect(player.playing.value).toBe(true)
|
||||
// Play same source again — should pause
|
||||
player.play('/audio/test.mp3', 'Test')
|
||||
expect(player.playing.value).toBe(false)
|
||||
})
|
||||
|
||||
it('play switches to new source', () => {
|
||||
const player = useAudioPlayer()
|
||||
player.play('/audio/first.mp3', 'First')
|
||||
player.play('/audio/second.mp3', 'Second')
|
||||
expect(player.currentSrc.value).toBe('/audio/second.mp3')
|
||||
expect(player.currentName.value).toBe('Second')
|
||||
})
|
||||
|
||||
it('pause pauses playback', () => {
|
||||
const player = useAudioPlayer()
|
||||
player.play('/audio/test.mp3', 'Test')
|
||||
player.pause()
|
||||
expect(player.playing.value).toBe(false)
|
||||
})
|
||||
|
||||
it('stop resets all state', () => {
|
||||
const player = useAudioPlayer()
|
||||
player.play('/audio/test.mp3', 'Test')
|
||||
player.stop()
|
||||
expect(player.playing.value).toBe(false)
|
||||
expect(player.currentSrc.value).toBeNull()
|
||||
expect(player.currentName.value).toBe('')
|
||||
})
|
||||
|
||||
it('progress computes correctly', () => {
|
||||
const player = useAudioPlayer()
|
||||
expect(player.progress.value).toBe(0) // duration is 0
|
||||
|
||||
player.currentTime.value = 30
|
||||
player.duration.value = 120
|
||||
expect(player.progress.value).toBe(25) // 30/120 * 100
|
||||
})
|
||||
|
||||
it('progress is 0 when duration is 0', () => {
|
||||
const player = useAudioPlayer()
|
||||
player.duration.value = 0
|
||||
player.currentTime.value = 10
|
||||
expect(player.progress.value).toBe(0)
|
||||
})
|
||||
|
||||
it('shared state across multiple useAudioPlayer calls', () => {
|
||||
const p1 = useAudioPlayer()
|
||||
const p2 = useAudioPlayer()
|
||||
p1.play('/audio/shared.mp3', 'Shared')
|
||||
expect(p2.currentSrc.value).toBe('/audio/shared.mp3')
|
||||
expect(p2.playing.value).toBe(true)
|
||||
})
|
||||
})
|
||||
266
neode-ui/src/composables/__tests__/useControllerNav.test.ts
Normal file
266
neode-ui/src/composables/__tests__/useControllerNav.test.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
// Mock vue-router
|
||||
const mockRoute = { path: '/dashboard' }
|
||||
const mockRouter = { push: vi.fn().mockResolvedValue(undefined) }
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => mockRoute,
|
||||
useRouter: () => mockRouter,
|
||||
}))
|
||||
|
||||
// Mock stores
|
||||
vi.mock('@/stores/controller', () => ({
|
||||
useControllerStore: () => ({
|
||||
setActive: vi.fn(),
|
||||
setGamepadCount: vi.fn(),
|
||||
isActive: false,
|
||||
gamepadCount: 0,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/spotlight', () => ({
|
||||
useSpotlightStore: () => ({
|
||||
isOpen: false,
|
||||
close: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/cli', () => ({
|
||||
useCLIStore: () => ({
|
||||
isOpen: false,
|
||||
close: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/appLauncher', () => ({
|
||||
useAppLauncherStore: () => ({
|
||||
isOpen: false,
|
||||
close: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useNavSounds
|
||||
vi.mock('@/composables/useNavSounds', () => ({
|
||||
playNavSound: vi.fn(),
|
||||
}))
|
||||
|
||||
// Note: The composable uses onMounted/onBeforeUnmount, so full integration tests
|
||||
// would require a mounted component with Pinia and Router. We test helper logic directly.
|
||||
|
||||
describe('useControllerNav - helper functions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
mockRoute.path = '/dashboard'
|
||||
|
||||
// Mock navigator.getGamepads
|
||||
Object.defineProperty(navigator, 'getGamepads', {
|
||||
value: vi.fn().mockReturnValue([null, null, null, null]),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
// Test the module exports via dynamic import to validate structure
|
||||
it('exports useControllerNav as a function', async () => {
|
||||
const mod = await import('../useControllerNav')
|
||||
expect(typeof mod.useControllerNav).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useControllerNav - nav key classification', () => {
|
||||
it('classifies arrow keys and Enter/Escape as nav keys', () => {
|
||||
const navKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape']
|
||||
expect(navKeys.includes('ArrowUp')).toBe(true)
|
||||
expect(navKeys.includes('ArrowDown')).toBe(true)
|
||||
expect(navKeys.includes('ArrowLeft')).toBe(true)
|
||||
expect(navKeys.includes('ArrowRight')).toBe(true)
|
||||
expect(navKeys.includes('Enter')).toBe(true)
|
||||
expect(navKeys.includes('Escape')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not classify regular keys as nav keys', () => {
|
||||
const navKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape']
|
||||
expect(navKeys.includes('a')).toBe(false)
|
||||
expect(navKeys.includes('Space')).toBe(false)
|
||||
expect(navKeys.includes('Tab')).toBe(false)
|
||||
})
|
||||
|
||||
it('recognizes detail page patterns', () => {
|
||||
const pattern = /\/apps\/[^/]+$|\/marketplace\/[^/]+$|\/cloud\/[^/]+$/
|
||||
expect(pattern.test('/apps/bitcoin')).toBe(true)
|
||||
expect(pattern.test('/marketplace/electrs')).toBe(true)
|
||||
expect(pattern.test('/cloud/photos')).toBe(true)
|
||||
expect(pattern.test('/dashboard')).toBe(false)
|
||||
expect(pattern.test('/apps')).toBe(false)
|
||||
})
|
||||
|
||||
it('recognizes page type patterns', () => {
|
||||
expect(/^\/dashboard(\/)?$/.test('/dashboard')).toBe(true)
|
||||
expect(/^\/dashboard(\/)?$/.test('/dashboard/')).toBe(true)
|
||||
expect(/^\/dashboard\/(apps|marketplace)(\/|$)/.test('/dashboard/apps')).toBe(true)
|
||||
expect(/^\/dashboard\/(apps|marketplace)(\/|$)/.test('/dashboard/marketplace')).toBe(true)
|
||||
expect(/^\/dashboard\/cloud(\/|$)/.test('/dashboard/cloud')).toBe(true)
|
||||
expect(/^\/dashboard\/server(\/|$)/.test('/dashboard/server')).toBe(true)
|
||||
expect(/^\/dashboard\/web5(\/|$)/.test('/dashboard/web5')).toBe(true)
|
||||
expect(/^\/dashboard\/settings(\/|$)/.test('/dashboard/settings')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useControllerNav - spatial navigation helpers', () => {
|
||||
// Test the internal helper functions indirectly via the FOCUSABLE_SELECTOR concept
|
||||
|
||||
it('identifies focusable elements', () => {
|
||||
const container = document.createElement('div')
|
||||
const button = document.createElement('button')
|
||||
button.textContent = 'Click'
|
||||
const link = document.createElement('a')
|
||||
link.href = '/test'
|
||||
link.textContent = 'Link'
|
||||
const disabledBtn = document.createElement('button')
|
||||
disabledBtn.disabled = true
|
||||
disabledBtn.textContent = 'Disabled'
|
||||
const input = document.createElement('input')
|
||||
|
||||
container.appendChild(button)
|
||||
container.appendChild(link)
|
||||
container.appendChild(disabledBtn)
|
||||
container.appendChild(input)
|
||||
document.body.appendChild(container)
|
||||
|
||||
const focusable = container.querySelectorAll(
|
||||
'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
|
||||
// Should find button, link, and input but NOT disabled button
|
||||
expect(focusable.length).toBe(3)
|
||||
|
||||
document.body.removeChild(container)
|
||||
})
|
||||
|
||||
it('respects data-controller-ignore attribute', () => {
|
||||
const container = document.createElement('div')
|
||||
const button = document.createElement('button')
|
||||
button.textContent = 'Visible'
|
||||
const ignoredBtn = document.createElement('button')
|
||||
ignoredBtn.textContent = 'Ignored'
|
||||
ignoredBtn.setAttribute('data-controller-ignore', '')
|
||||
|
||||
container.appendChild(button)
|
||||
container.appendChild(ignoredBtn)
|
||||
document.body.appendChild(container)
|
||||
|
||||
const focusable = Array.from(
|
||||
container.querySelectorAll<HTMLElement>('button:not([disabled])')
|
||||
).filter(el => !el.hasAttribute('data-controller-ignore'))
|
||||
|
||||
expect(focusable.length).toBe(1)
|
||||
expect(focusable[0]?.textContent).toBe('Visible')
|
||||
|
||||
document.body.removeChild(container)
|
||||
})
|
||||
|
||||
it('identifies sidebar and main zones', () => {
|
||||
const sidebar = document.createElement('div')
|
||||
sidebar.setAttribute('data-controller-zone', 'sidebar')
|
||||
const main = document.createElement('div')
|
||||
main.setAttribute('data-controller-zone', 'main')
|
||||
|
||||
const sideBtn = document.createElement('button')
|
||||
sideBtn.textContent = 'Nav'
|
||||
sidebar.appendChild(sideBtn)
|
||||
|
||||
const mainBtn = document.createElement('button')
|
||||
mainBtn.textContent = 'Content'
|
||||
main.appendChild(mainBtn)
|
||||
|
||||
document.body.appendChild(sidebar)
|
||||
document.body.appendChild(main)
|
||||
|
||||
// isInZone check
|
||||
expect(sideBtn.closest('[data-controller-zone="sidebar"]')).toBeTruthy()
|
||||
expect(mainBtn.closest('[data-controller-zone="main"]')).toBeTruthy()
|
||||
expect(sideBtn.closest('[data-controller-zone="main"]')).toBeNull()
|
||||
|
||||
document.body.removeChild(sidebar)
|
||||
document.body.removeChild(main)
|
||||
})
|
||||
|
||||
it('identifies container elements', () => {
|
||||
const container = document.createElement('div')
|
||||
container.setAttribute('data-controller-container', '')
|
||||
container.tabIndex = 0
|
||||
|
||||
const innerBtn = document.createElement('button')
|
||||
innerBtn.textContent = 'Inner'
|
||||
container.appendChild(innerBtn)
|
||||
|
||||
document.body.appendChild(container)
|
||||
|
||||
// isInsideContainer check
|
||||
expect(innerBtn.closest('[data-controller-container]')).toBe(container)
|
||||
expect(container.closest('[data-controller-container]')).toBe(container)
|
||||
|
||||
document.body.removeChild(container)
|
||||
})
|
||||
|
||||
it('finds inner focusable elements within containers', () => {
|
||||
const container = document.createElement('div')
|
||||
container.setAttribute('data-controller-container', '')
|
||||
container.tabIndex = 0
|
||||
|
||||
const btn1 = document.createElement('button')
|
||||
btn1.textContent = 'Action 1'
|
||||
const btn2 = document.createElement('button')
|
||||
btn2.textContent = 'Action 2'
|
||||
|
||||
container.appendChild(btn1)
|
||||
container.appendChild(btn2)
|
||||
document.body.appendChild(container)
|
||||
|
||||
const inner = Array.from(
|
||||
container.querySelectorAll<HTMLElement>('button:not([disabled])')
|
||||
).filter(el => el !== container)
|
||||
|
||||
expect(inner.length).toBe(2)
|
||||
|
||||
document.body.removeChild(container)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useControllerNav - gamepad detection', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('counts connected gamepads', () => {
|
||||
const gamepads = [
|
||||
{ connected: true } as Gamepad,
|
||||
null,
|
||||
{ connected: true } as Gamepad,
|
||||
null,
|
||||
]
|
||||
|
||||
const count = gamepads.filter((g) => g?.connected).length
|
||||
expect(count).toBe(2)
|
||||
})
|
||||
|
||||
it('handles null gamepad list', () => {
|
||||
// Simulate navigator.getGamepads returning null (some browsers)
|
||||
function getCount(gp: (Gamepad | null)[] | null): number {
|
||||
return gp ? gp.filter((g) => g?.connected).length : 0
|
||||
}
|
||||
expect(getCount(null)).toBe(0)
|
||||
})
|
||||
|
||||
it('handles empty gamepad list', () => {
|
||||
const gamepads: (Gamepad | null)[] = [null, null, null, null]
|
||||
const count = Array.from(gamepads).filter((g) => g?.connected).length
|
||||
expect(count).toBe(0)
|
||||
})
|
||||
})
|
||||
202
neode-ui/src/composables/__tests__/useFileType.test.ts
Normal file
202
neode-ui/src/composables/__tests__/useFileType.test.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { getFileCategory, useFileType, formatSize, formatDate } from '../useFileType'
|
||||
|
||||
describe('getFileCategory', () => {
|
||||
it('returns folder for directories', () => {
|
||||
expect(getFileCategory('', true)).toBe('folder')
|
||||
expect(getFileCategory('jpg', true)).toBe('folder')
|
||||
})
|
||||
|
||||
it('identifies image extensions', () => {
|
||||
expect(getFileCategory('jpg', false)).toBe('image')
|
||||
expect(getFileCategory('jpeg', false)).toBe('image')
|
||||
expect(getFileCategory('png', false)).toBe('image')
|
||||
expect(getFileCategory('gif', false)).toBe('image')
|
||||
expect(getFileCategory('webp', false)).toBe('image')
|
||||
expect(getFileCategory('svg', false)).toBe('image')
|
||||
expect(getFileCategory('bmp', false)).toBe('image')
|
||||
expect(getFileCategory('ico', false)).toBe('image')
|
||||
})
|
||||
|
||||
it('identifies audio extensions', () => {
|
||||
expect(getFileCategory('mp3', false)).toBe('audio')
|
||||
expect(getFileCategory('flac', false)).toBe('audio')
|
||||
expect(getFileCategory('wav', false)).toBe('audio')
|
||||
expect(getFileCategory('ogg', false)).toBe('audio')
|
||||
expect(getFileCategory('aac', false)).toBe('audio')
|
||||
expect(getFileCategory('m4a', false)).toBe('audio')
|
||||
})
|
||||
|
||||
it('identifies video extensions', () => {
|
||||
expect(getFileCategory('mp4', false)).toBe('video')
|
||||
expect(getFileCategory('mkv', false)).toBe('video')
|
||||
expect(getFileCategory('avi', false)).toBe('video')
|
||||
expect(getFileCategory('mov', false)).toBe('video')
|
||||
expect(getFileCategory('webm', false)).toBe('video')
|
||||
})
|
||||
|
||||
it('identifies document extensions', () => {
|
||||
expect(getFileCategory('pdf', false)).toBe('document')
|
||||
expect(getFileCategory('doc', false)).toBe('document')
|
||||
expect(getFileCategory('docx', false)).toBe('document')
|
||||
expect(getFileCategory('txt', false)).toBe('document')
|
||||
expect(getFileCategory('md', false)).toBe('document')
|
||||
})
|
||||
|
||||
it('identifies spreadsheet extensions', () => {
|
||||
expect(getFileCategory('xls', false)).toBe('spreadsheet')
|
||||
expect(getFileCategory('xlsx', false)).toBe('spreadsheet')
|
||||
expect(getFileCategory('csv', false)).toBe('spreadsheet')
|
||||
expect(getFileCategory('ods', false)).toBe('spreadsheet')
|
||||
})
|
||||
|
||||
it('identifies archive extensions', () => {
|
||||
expect(getFileCategory('zip', false)).toBe('archive')
|
||||
expect(getFileCategory('tar', false)).toBe('archive')
|
||||
expect(getFileCategory('gz', false)).toBe('archive')
|
||||
expect(getFileCategory('rar', false)).toBe('archive')
|
||||
expect(getFileCategory('7z', false)).toBe('archive')
|
||||
})
|
||||
|
||||
it('returns file for unknown extensions', () => {
|
||||
expect(getFileCategory('xyz', false)).toBe('file')
|
||||
expect(getFileCategory('', false)).toBe('file')
|
||||
expect(getFileCategory('bin', false)).toBe('file')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useFileType', () => {
|
||||
it('returns correct category and computed values for an image', () => {
|
||||
const ext = ref('jpg')
|
||||
const isDir = ref(false)
|
||||
const result = useFileType(ext, isDir)
|
||||
|
||||
expect(result.category.value).toBe('image')
|
||||
expect(result.isImage.value).toBe(true)
|
||||
expect(result.isAudio.value).toBe(false)
|
||||
expect(result.isVideo.value).toBe(false)
|
||||
expect(result.iconColor.value).toBe('text-blue-400')
|
||||
expect(result.badgeLabel.value).toBe('Image')
|
||||
})
|
||||
|
||||
it('returns correct values for audio', () => {
|
||||
const ext = ref('mp3')
|
||||
const isDir = ref(false)
|
||||
const result = useFileType(ext, isDir)
|
||||
|
||||
expect(result.category.value).toBe('audio')
|
||||
expect(result.isAudio.value).toBe(true)
|
||||
expect(result.isImage.value).toBe(false)
|
||||
expect(result.iconColor.value).toBe('text-orange-400')
|
||||
expect(result.badgeLabel.value).toBe('Audio')
|
||||
})
|
||||
|
||||
it('returns correct values for video', () => {
|
||||
const ext = ref('mp4')
|
||||
const isDir = ref(false)
|
||||
const result = useFileType(ext, isDir)
|
||||
|
||||
expect(result.category.value).toBe('video')
|
||||
expect(result.isVideo.value).toBe(true)
|
||||
expect(result.iconColor.value).toBe('text-purple-400')
|
||||
})
|
||||
|
||||
it('returns folder when isDir is true', () => {
|
||||
const ext = ref('jpg')
|
||||
const isDir = ref(true)
|
||||
const result = useFileType(ext, isDir)
|
||||
|
||||
expect(result.category.value).toBe('folder')
|
||||
expect(result.isImage.value).toBe(false)
|
||||
expect(result.iconColor.value).toBe('text-amber-400')
|
||||
expect(result.badgeLabel.value).toBe('Folder')
|
||||
})
|
||||
|
||||
it('reacts to ref changes', () => {
|
||||
const ext = ref('jpg')
|
||||
const isDir = ref(false)
|
||||
const result = useFileType(ext, isDir)
|
||||
|
||||
expect(result.category.value).toBe('image')
|
||||
|
||||
ext.value = 'mp3'
|
||||
expect(result.category.value).toBe('audio')
|
||||
expect(result.isAudio.value).toBe(true)
|
||||
expect(result.isImage.value).toBe(false)
|
||||
})
|
||||
|
||||
it('provides icon paths for each category', () => {
|
||||
const ext = ref('pdf')
|
||||
const isDir = ref(false)
|
||||
const result = useFileType(ext, isDir)
|
||||
|
||||
expect(result.iconPaths.value).toBeDefined()
|
||||
expect(result.iconPaths.value.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('provides badge class for each category', () => {
|
||||
const ext = ref('zip')
|
||||
const isDir = ref(false)
|
||||
const result = useFileType(ext, isDir)
|
||||
|
||||
expect(result.badgeClass.value).toContain('bg-yellow')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatSize', () => {
|
||||
it('formats 0 bytes', () => {
|
||||
expect(formatSize(0)).toBe('0 B')
|
||||
})
|
||||
|
||||
it('formats bytes', () => {
|
||||
expect(formatSize(500)).toBe('500 B')
|
||||
})
|
||||
|
||||
it('formats kilobytes', () => {
|
||||
expect(formatSize(1024)).toBe('1.0 KB')
|
||||
expect(formatSize(1536)).toBe('1.5 KB')
|
||||
})
|
||||
|
||||
it('formats megabytes', () => {
|
||||
expect(formatSize(1048576)).toBe('1.0 MB')
|
||||
})
|
||||
|
||||
it('formats gigabytes', () => {
|
||||
expect(formatSize(1073741824)).toBe('1.0 GB')
|
||||
})
|
||||
|
||||
it('formats terabytes', () => {
|
||||
expect(formatSize(1099511627776)).toBe('1.0 TB')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('returns "Just now" for very recent dates', () => {
|
||||
const now = new Date().toISOString()
|
||||
expect(formatDate(now)).toBe('Just now')
|
||||
})
|
||||
|
||||
it('returns minutes ago for recent dates', () => {
|
||||
const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString()
|
||||
expect(formatDate(fiveMinAgo)).toBe('5m ago')
|
||||
})
|
||||
|
||||
it('returns hours ago for dates within 24h', () => {
|
||||
const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString()
|
||||
expect(formatDate(threeHoursAgo)).toBe('3h ago')
|
||||
})
|
||||
|
||||
it('returns days ago for dates within a week', () => {
|
||||
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString()
|
||||
expect(formatDate(twoDaysAgo)).toBe('2d ago')
|
||||
})
|
||||
|
||||
it('returns formatted date for older dates', () => {
|
||||
const oldDate = new Date('2025-01-15').toISOString()
|
||||
const result = formatDate(oldDate)
|
||||
// Should be a locale date string, not a relative time
|
||||
expect(result).toMatch(/\d/)
|
||||
expect(result).not.toContain('ago')
|
||||
})
|
||||
})
|
||||
211
neode-ui/src/composables/__tests__/useLoginSounds.test.ts
Normal file
211
neode-ui/src/composables/__tests__/useLoginSounds.test.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// Mock Audio globally
|
||||
class MockAudio {
|
||||
src = ''
|
||||
volume = 1
|
||||
loop = false
|
||||
currentTime = 0
|
||||
play = vi.fn().mockResolvedValue(undefined)
|
||||
pause = vi.fn()
|
||||
addEventListener = vi.fn()
|
||||
}
|
||||
|
||||
vi.stubGlobal('Audio', MockAudio)
|
||||
|
||||
// Mock fetch for playLoopStart
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)),
|
||||
}))
|
||||
|
||||
// Mock AudioContext
|
||||
const mockBufferSource = {
|
||||
buffer: null as AudioBuffer | null,
|
||||
connect: vi.fn(),
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
}
|
||||
|
||||
const mockMediaElementSource = {
|
||||
connect: vi.fn(),
|
||||
}
|
||||
|
||||
const mockGainNode = {
|
||||
gain: {
|
||||
value: 1,
|
||||
setValueAtTime: vi.fn(),
|
||||
linearRampToValueAtTime: vi.fn(),
|
||||
exponentialRampToValueAtTime: vi.fn(),
|
||||
},
|
||||
connect: vi.fn(),
|
||||
}
|
||||
|
||||
const mockAudioContext = {
|
||||
state: 'running' as AudioContextState,
|
||||
currentTime: 0,
|
||||
destination: {},
|
||||
resume: vi.fn().mockResolvedValue(undefined),
|
||||
createOscillator: vi.fn().mockReturnValue({
|
||||
type: 'sine',
|
||||
frequency: { value: 440, setValueAtTime: vi.fn() },
|
||||
connect: vi.fn(),
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
}),
|
||||
createGain: vi.fn().mockReturnValue({ ...mockGainNode, gain: { ...mockGainNode.gain } }),
|
||||
createBufferSource: vi.fn().mockReturnValue({ ...mockBufferSource }),
|
||||
createMediaElementSource: vi.fn().mockReturnValue({ ...mockMediaElementSource }),
|
||||
decodeAudioData: vi.fn().mockResolvedValue({} as AudioBuffer),
|
||||
}
|
||||
|
||||
vi.stubGlobal('AudioContext', vi.fn().mockImplementation(() => ({ ...mockAudioContext })))
|
||||
|
||||
import {
|
||||
playPop,
|
||||
playLoginSuccessWhoosh,
|
||||
playTypingSound,
|
||||
playIntroTyping,
|
||||
stopIntroTyping,
|
||||
playWelcomeNoderunnerSpeech,
|
||||
playTypingTick,
|
||||
resumeAudioContext,
|
||||
startSynthwave,
|
||||
stopSynthwave,
|
||||
playLoopStart,
|
||||
playKeyboardTypingSound,
|
||||
playDashboardLoadOomph,
|
||||
} from '../useLoginSounds'
|
||||
|
||||
describe('useLoginSounds', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('playPop', () => {
|
||||
it('creates Audio with pop.mp3 and plays it', () => {
|
||||
playPop()
|
||||
// Audio constructor was called (via MockAudio)
|
||||
expect(MockAudio.prototype.constructor).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not throw', () => {
|
||||
expect(() => playPop()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('playLoginSuccessWhoosh', () => {
|
||||
it('does not throw', () => {
|
||||
expect(() => playLoginSuccessWhoosh()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('playTypingSound', () => {
|
||||
it('does not throw', () => {
|
||||
expect(() => playTypingSound()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('playIntroTyping', () => {
|
||||
it('does not throw', () => {
|
||||
expect(() => playIntroTyping()).not.toThrow()
|
||||
})
|
||||
|
||||
it('creates a looping audio element', () => {
|
||||
playIntroTyping()
|
||||
// Does not throw, creates audio
|
||||
})
|
||||
})
|
||||
|
||||
describe('stopIntroTyping', () => {
|
||||
it('does not throw when no audio playing', () => {
|
||||
expect(() => stopIntroTyping()).not.toThrow()
|
||||
})
|
||||
|
||||
it('stops audio that was started', () => {
|
||||
playIntroTyping()
|
||||
expect(() => stopIntroTyping()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('playWelcomeNoderunnerSpeech', () => {
|
||||
it('does not throw', () => {
|
||||
expect(() => playWelcomeNoderunnerSpeech()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('playTypingTick', () => {
|
||||
it('does not throw', () => {
|
||||
expect(() => playTypingTick()).not.toThrow()
|
||||
})
|
||||
|
||||
it('can be called multiple times (pool rotation)', () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
expect(() => playTypingTick()).not.toThrow()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('resumeAudioContext', () => {
|
||||
it('does not throw', () => {
|
||||
expect(() => resumeAudioContext()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('startSynthwave', () => {
|
||||
it('does not throw when no audio context', () => {
|
||||
// Without calling resumeAudioContext first, context might be null
|
||||
expect(() => startSynthwave()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('stopSynthwave', () => {
|
||||
it('does not throw when nothing is playing', () => {
|
||||
expect(() => stopSynthwave()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('playLoopStart', () => {
|
||||
it('does not throw when no audio context', () => {
|
||||
expect(() => playLoopStart()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('playKeyboardTypingSound', () => {
|
||||
it('does not throw when no audio context', () => {
|
||||
expect(() => playKeyboardTypingSound()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('playDashboardLoadOomph', () => {
|
||||
it('does not throw when no audio context', () => {
|
||||
expect(() => playDashboardLoadOomph()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('audio context lifecycle', () => {
|
||||
it('resumeAudioContext then startSynthwave does not throw', () => {
|
||||
resumeAudioContext()
|
||||
expect(() => startSynthwave()).not.toThrow()
|
||||
})
|
||||
|
||||
it('resumeAudioContext then stopSynthwave does not throw', () => {
|
||||
resumeAudioContext()
|
||||
expect(() => stopSynthwave()).not.toThrow()
|
||||
})
|
||||
|
||||
it('resumeAudioContext then playKeyboardTypingSound does not throw', () => {
|
||||
resumeAudioContext()
|
||||
expect(() => playKeyboardTypingSound()).not.toThrow()
|
||||
})
|
||||
|
||||
it('resumeAudioContext then playDashboardLoadOomph does not throw', () => {
|
||||
resumeAudioContext()
|
||||
expect(() => playDashboardLoadOomph()).not.toThrow()
|
||||
})
|
||||
|
||||
it('resumeAudioContext then playLoopStart does not throw', () => {
|
||||
resumeAudioContext()
|
||||
expect(() => playLoopStart()).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
97
neode-ui/src/composables/__tests__/useMarketplaceApp.test.ts
Normal file
97
neode-ui/src/composables/__tests__/useMarketplaceApp.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { useMarketplaceApp } from '../useMarketplaceApp'
|
||||
|
||||
describe('useMarketplaceApp', () => {
|
||||
beforeEach(() => {
|
||||
const { clearCurrentApp } = useMarketplaceApp()
|
||||
clearCurrentApp()
|
||||
})
|
||||
|
||||
it('getCurrentApp returns null initially', () => {
|
||||
const { getCurrentApp } = useMarketplaceApp()
|
||||
expect(getCurrentApp()).toBeNull()
|
||||
})
|
||||
|
||||
it('setCurrentApp stores a full app', () => {
|
||||
const { setCurrentApp, getCurrentApp } = useMarketplaceApp()
|
||||
setCurrentApp({
|
||||
id: 'bitcoin',
|
||||
title: 'Bitcoin Core',
|
||||
version: '25.0',
|
||||
icon: '/icons/btc.png',
|
||||
category: 'Finance',
|
||||
description: 'Bitcoin node',
|
||||
author: 'Satoshi',
|
||||
source: 'github',
|
||||
manifestUrl: 'https://example.com/manifest',
|
||||
url: 'https://example.com',
|
||||
repoUrl: 'https://github.com/bitcoin/bitcoin',
|
||||
s9pkUrl: '',
|
||||
dockerImage: 'bitcoin:25.0',
|
||||
})
|
||||
|
||||
const app = getCurrentApp()
|
||||
expect(app).not.toBeNull()
|
||||
expect(app!.id).toBe('bitcoin')
|
||||
expect(app!.title).toBe('Bitcoin Core')
|
||||
expect(app!.version).toBe('25.0')
|
||||
})
|
||||
|
||||
it('setCurrentApp with partial app fills defaults', () => {
|
||||
const { setCurrentApp, getCurrentApp } = useMarketplaceApp()
|
||||
setCurrentApp({ id: 'lnd' })
|
||||
|
||||
const app = getCurrentApp()
|
||||
expect(app).not.toBeNull()
|
||||
expect(app!.id).toBe('lnd')
|
||||
expect(app!.title).toBe('')
|
||||
expect(app!.version).toBe('')
|
||||
expect(app!.icon).toBe('')
|
||||
expect(app!.dockerImage).toBe('')
|
||||
})
|
||||
|
||||
it('manifestUrl falls back to s9pkUrl then url', () => {
|
||||
const { setCurrentApp, getCurrentApp } = useMarketplaceApp()
|
||||
setCurrentApp({ id: 'test', s9pkUrl: 'https://s9pk.example.com/app.s9pk' })
|
||||
|
||||
const app = getCurrentApp()
|
||||
expect(app!.manifestUrl).toBe('https://s9pk.example.com/app.s9pk')
|
||||
expect(app!.url).toBe('https://s9pk.example.com/app.s9pk')
|
||||
})
|
||||
|
||||
it('url falls back to s9pkUrl then manifestUrl', () => {
|
||||
const { setCurrentApp, getCurrentApp } = useMarketplaceApp()
|
||||
setCurrentApp({ id: 'test', manifestUrl: 'https://manifest.example.com' })
|
||||
|
||||
const app = getCurrentApp()
|
||||
expect(app!.url).toBe('https://manifest.example.com')
|
||||
})
|
||||
|
||||
it('clearCurrentApp sets app to null', () => {
|
||||
const { setCurrentApp, clearCurrentApp, getCurrentApp } = useMarketplaceApp()
|
||||
setCurrentApp({ id: 'bitcoin' })
|
||||
expect(getCurrentApp()).not.toBeNull()
|
||||
clearCurrentApp()
|
||||
expect(getCurrentApp()).toBeNull()
|
||||
})
|
||||
|
||||
it('shared state across multiple useMarketplaceApp calls', () => {
|
||||
const instance1 = useMarketplaceApp()
|
||||
const instance2 = useMarketplaceApp()
|
||||
|
||||
instance1.setCurrentApp({ id: 'mempool', title: 'Mempool' })
|
||||
const app = instance2.getCurrentApp()
|
||||
expect(app!.id).toBe('mempool')
|
||||
expect(app!.title).toBe('Mempool')
|
||||
})
|
||||
|
||||
it('handles description as object', () => {
|
||||
const { setCurrentApp, getCurrentApp } = useMarketplaceApp()
|
||||
setCurrentApp({
|
||||
id: 'test',
|
||||
description: { short: 'Short desc', long: 'Long description' },
|
||||
})
|
||||
const app = getCurrentApp()
|
||||
expect(app!.description).toEqual({ short: 'Short desc', long: 'Long description' })
|
||||
})
|
||||
})
|
||||
178
neode-ui/src/composables/__tests__/useMessageToast.test.ts
Normal file
178
neode-ui/src/composables/__tests__/useMessageToast.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
getReceivedMessages: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { useMessageToast } from '../useMessageToast'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
const mockedRpc = vi.mocked(rpcClient)
|
||||
|
||||
describe('useMessageToast', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
// Reset shared singleton state
|
||||
const toast = useMessageToast()
|
||||
toast.stopPolling()
|
||||
toast.receivedMessages.value = []
|
||||
toast.lastMessageCount.value = 0
|
||||
toast.loadingMessages.value = false
|
||||
toast.toastMessage.value = { show: false, text: '' }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
const toast = useMessageToast()
|
||||
toast.stopPolling()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('starts with empty state', () => {
|
||||
const toast = useMessageToast()
|
||||
expect(toast.receivedMessages.value).toEqual([])
|
||||
expect(toast.lastMessageCount.value).toBe(0)
|
||||
expect(toast.loadingMessages.value).toBe(false)
|
||||
expect(toast.toastMessage.value.show).toBe(false)
|
||||
expect(toast.unreadCount.value).toBe(0)
|
||||
})
|
||||
|
||||
it('loadReceivedMessages fetches and stores messages', async () => {
|
||||
mockedRpc.getReceivedMessages.mockResolvedValue({
|
||||
messages: [
|
||||
{ from_pubkey: 'abc', message: 'Hello', timestamp: '2026-01-01' },
|
||||
],
|
||||
})
|
||||
const toast = useMessageToast()
|
||||
await toast.loadReceivedMessages()
|
||||
|
||||
expect(toast.receivedMessages.value.length).toBe(1)
|
||||
expect(toast.lastMessageCount.value).toBe(1)
|
||||
expect(toast.loadingMessages.value).toBe(false)
|
||||
})
|
||||
|
||||
it('does not show toast on initial load', async () => {
|
||||
mockedRpc.getReceivedMessages.mockResolvedValue({
|
||||
messages: [{ from_pubkey: 'a', message: 'Hi', timestamp: '2026-01-01' }],
|
||||
})
|
||||
const toast = useMessageToast()
|
||||
await toast.loadReceivedMessages()
|
||||
|
||||
expect(toast.toastMessage.value.show).toBe(false)
|
||||
})
|
||||
|
||||
it('shows toast when new messages arrive after initial load', async () => {
|
||||
const toast = useMessageToast()
|
||||
|
||||
// Initial load
|
||||
mockedRpc.getReceivedMessages.mockResolvedValue({
|
||||
messages: [{ from_pubkey: 'a', message: 'First', timestamp: '2026-01-01' }],
|
||||
})
|
||||
await toast.loadReceivedMessages()
|
||||
|
||||
// New message arrives
|
||||
mockedRpc.getReceivedMessages.mockResolvedValue({
|
||||
messages: [
|
||||
{ from_pubkey: 'a', message: 'First', timestamp: '2026-01-01' },
|
||||
{ from_pubkey: 'b', message: 'Second', timestamp: '2026-01-02' },
|
||||
],
|
||||
})
|
||||
await toast.loadReceivedMessages()
|
||||
|
||||
expect(toast.toastMessage.value.show).toBe(true)
|
||||
expect(toast.toastMessage.value.text).toBe('Second')
|
||||
})
|
||||
|
||||
it('shows count for multiple new messages', async () => {
|
||||
const toast = useMessageToast()
|
||||
|
||||
// Initial load
|
||||
mockedRpc.getReceivedMessages.mockResolvedValue({
|
||||
messages: [{ from_pubkey: 'a', message: 'One', timestamp: '2026-01-01' }],
|
||||
})
|
||||
await toast.loadReceivedMessages()
|
||||
|
||||
// Multiple new messages
|
||||
mockedRpc.getReceivedMessages.mockResolvedValue({
|
||||
messages: [
|
||||
{ from_pubkey: 'a', message: 'One', timestamp: '2026-01-01' },
|
||||
{ from_pubkey: 'b', message: 'Two', timestamp: '2026-01-02' },
|
||||
{ from_pubkey: 'c', message: 'Three', timestamp: '2026-01-03' },
|
||||
],
|
||||
})
|
||||
await toast.loadReceivedMessages()
|
||||
|
||||
expect(toast.toastMessage.value.show).toBe(true)
|
||||
expect(toast.toastMessage.value.text).toBe('2 new messages')
|
||||
})
|
||||
|
||||
it('unreadCount reflects difference', async () => {
|
||||
const toast = useMessageToast()
|
||||
toast.receivedMessages.value = [
|
||||
{ from_pubkey: 'a', message: 'Hi', timestamp: '2026-01-01' },
|
||||
{ from_pubkey: 'b', message: 'Hey', timestamp: '2026-01-02' },
|
||||
]
|
||||
toast.lastMessageCount.value = 1
|
||||
expect(toast.unreadCount.value).toBe(1)
|
||||
})
|
||||
|
||||
it('unreadCount is never negative', () => {
|
||||
const toast = useMessageToast()
|
||||
toast.receivedMessages.value = []
|
||||
toast.lastMessageCount.value = 5
|
||||
expect(toast.unreadCount.value).toBe(0)
|
||||
})
|
||||
|
||||
it('markAsRead syncs lastMessageCount', () => {
|
||||
const toast = useMessageToast()
|
||||
toast.receivedMessages.value = [
|
||||
{ from_pubkey: 'a', message: 'Hi', timestamp: '2026-01-01' },
|
||||
{ from_pubkey: 'b', message: 'Hey', timestamp: '2026-01-02' },
|
||||
]
|
||||
toast.lastMessageCount.value = 0
|
||||
toast.markAsRead()
|
||||
expect(toast.lastMessageCount.value).toBe(2)
|
||||
expect(toast.unreadCount.value).toBe(0)
|
||||
})
|
||||
|
||||
it('dismissToastAndOpenMessages clears toast and navigates', () => {
|
||||
const toast = useMessageToast()
|
||||
toast.toastMessage.value = { show: true, text: 'New message' }
|
||||
toast.dismissToastAndOpenMessages()
|
||||
|
||||
expect(toast.toastMessage.value.show).toBe(false)
|
||||
expect(mockPush).toHaveBeenCalledWith({ path: '/dashboard/web5', query: { tab: 'messages' } })
|
||||
})
|
||||
|
||||
it('stops polling on 401 error', async () => {
|
||||
const toast = useMessageToast()
|
||||
mockedRpc.getReceivedMessages.mockRejectedValue(new Error('401 Unauthorized'))
|
||||
toast.startPolling()
|
||||
|
||||
// Wait for initial load triggered by startPolling
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
// Polling should have stopped, so advancing time should NOT call again
|
||||
vi.clearAllMocks()
|
||||
await vi.advanceTimersByTimeAsync(60000)
|
||||
expect(mockedRpc.getReceivedMessages).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('startPolling does not create duplicate timers', () => {
|
||||
const toast = useMessageToast()
|
||||
mockedRpc.getReceivedMessages.mockResolvedValue({ messages: [] })
|
||||
toast.startPolling()
|
||||
toast.startPolling()
|
||||
toast.startPolling()
|
||||
// Should only have one timer — verify by stopping and checking no more calls
|
||||
toast.stopPolling()
|
||||
})
|
||||
})
|
||||
127
neode-ui/src/composables/__tests__/useMobileBackButton.test.ts
Normal file
127
neode-ui/src/composables/__tests__/useMobileBackButton.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
import { useMobileBackButton } from '../useMobileBackButton'
|
||||
|
||||
// Helper component that uses the composable
|
||||
const TestComponent = defineComponent({
|
||||
setup() {
|
||||
return useMobileBackButton()
|
||||
},
|
||||
template: '<div>{{ bottomPosition }}</div>',
|
||||
})
|
||||
|
||||
describe('useMobileBackButton', () => {
|
||||
let wrapper: ReturnType<typeof mount>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('returns bottomPosition, bottomClass, and tabBarHeight', () => {
|
||||
wrapper = mount(TestComponent)
|
||||
const vm = wrapper.vm as unknown as {
|
||||
bottomPosition: string
|
||||
bottomClass: string
|
||||
tabBarHeight: number
|
||||
}
|
||||
|
||||
expect(typeof vm.bottomPosition).toBe('string')
|
||||
expect(typeof vm.bottomClass).toBe('string')
|
||||
expect(typeof vm.tabBarHeight).toBe('number')
|
||||
})
|
||||
|
||||
it('defaults tabBarHeight to 72', () => {
|
||||
wrapper = mount(TestComponent)
|
||||
const vm = wrapper.vm as unknown as { tabBarHeight: number }
|
||||
expect(vm.tabBarHeight).toBe(72)
|
||||
})
|
||||
|
||||
it('computes bottomPosition as tabBarHeight + 8', () => {
|
||||
wrapper = mount(TestComponent)
|
||||
const vm = wrapper.vm as unknown as {
|
||||
bottomPosition: string
|
||||
tabBarHeight: number
|
||||
}
|
||||
expect(vm.bottomPosition).toBe('80px') // 72 + 8
|
||||
})
|
||||
|
||||
it('computes bottomClass with Tailwind arbitrary value', () => {
|
||||
wrapper = mount(TestComponent)
|
||||
const vm = wrapper.vm as unknown as { bottomClass: string }
|
||||
expect(vm.bottomClass).toBe('bottom-[80px]')
|
||||
})
|
||||
|
||||
it('reads tabBar element if present', async () => {
|
||||
// Create mock tab bar element
|
||||
const tabBar = document.createElement('div')
|
||||
tabBar.setAttribute('data-mobile-tab-bar', '')
|
||||
Object.defineProperty(tabBar, 'offsetHeight', { value: 56 })
|
||||
document.body.appendChild(tabBar)
|
||||
|
||||
wrapper = mount(TestComponent)
|
||||
await nextTick()
|
||||
|
||||
const vm = wrapper.vm as unknown as { tabBarHeight: number }
|
||||
expect(vm.tabBarHeight).toBe(56)
|
||||
|
||||
document.body.removeChild(tabBar)
|
||||
})
|
||||
|
||||
it('falls back to CSS variable when no tab bar element', async () => {
|
||||
document.documentElement.style.setProperty('--mobile-tab-bar-height', '64')
|
||||
|
||||
wrapper = mount(TestComponent)
|
||||
await nextTick()
|
||||
|
||||
const vm = wrapper.vm as unknown as { tabBarHeight: number }
|
||||
expect(vm.tabBarHeight).toBe(64)
|
||||
|
||||
document.documentElement.style.removeProperty('--mobile-tab-bar-height')
|
||||
})
|
||||
|
||||
it('keeps default when no tab bar or CSS var', async () => {
|
||||
wrapper = mount(TestComponent)
|
||||
await nextTick()
|
||||
|
||||
const vm = wrapper.vm as unknown as { tabBarHeight: number }
|
||||
// Should keep the default of 72
|
||||
expect(vm.tabBarHeight).toBe(72)
|
||||
})
|
||||
|
||||
it('cleans up observers on unmount', () => {
|
||||
wrapper = mount(TestComponent)
|
||||
const removeEventSpy = vi.spyOn(window, 'removeEventListener')
|
||||
wrapper.unmount()
|
||||
expect(removeEventSpy).toHaveBeenCalled()
|
||||
removeEventSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('updates on window resize', async () => {
|
||||
const tabBar = document.createElement('div')
|
||||
tabBar.setAttribute('data-mobile-tab-bar', '')
|
||||
Object.defineProperty(tabBar, 'offsetHeight', {
|
||||
value: 48,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
document.body.appendChild(tabBar)
|
||||
|
||||
wrapper = mount(TestComponent)
|
||||
await nextTick()
|
||||
|
||||
// Trigger resize
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
await nextTick()
|
||||
|
||||
const vm = wrapper.vm as unknown as { tabBarHeight: number }
|
||||
expect(vm.tabBarHeight).toBe(48)
|
||||
|
||||
document.body.removeChild(tabBar)
|
||||
})
|
||||
})
|
||||
74
neode-ui/src/composables/__tests__/useModalKeyboard.test.ts
Normal file
74
neode-ui/src/composables/__tests__/useModalKeyboard.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { useModalKeyboard } from '../useModalKeyboard'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
// We need to test the composable inside a component
|
||||
function createTestComponent(onCloseFn: () => void) {
|
||||
return defineComponent({
|
||||
setup() {
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const isOpen = ref(false)
|
||||
const restoreFocusRef = ref<HTMLElement | null>(null)
|
||||
|
||||
useModalKeyboard(containerRef, isOpen, onCloseFn, {
|
||||
restoreFocusRef,
|
||||
})
|
||||
|
||||
return { containerRef, isOpen, restoreFocusRef }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<button id="trigger">Trigger</button>
|
||||
<div v-if="isOpen" ref="containerRef">
|
||||
<button id="btn1">One</button>
|
||||
<button id="btn2">Two</button>
|
||||
<button id="btn3">Three</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
}
|
||||
|
||||
describe('useModalKeyboard', () => {
|
||||
let closeFn: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
closeFn = vi.fn()
|
||||
})
|
||||
|
||||
it('calls onClose when Escape is pressed and modal is open', async () => {
|
||||
const Comp = createTestComponent(closeFn)
|
||||
const wrapper = mount(Comp, { attachTo: document.body })
|
||||
|
||||
wrapper.vm.isOpen = true
|
||||
await nextTick()
|
||||
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
|
||||
expect(closeFn).toHaveBeenCalledOnce()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not call onClose when modal is closed', () => {
|
||||
const Comp = createTestComponent(closeFn)
|
||||
const wrapper = mount(Comp, { attachTo: document.body })
|
||||
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
|
||||
expect(closeFn).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('cleans up listener on unmount', () => {
|
||||
const removeSpy = vi.spyOn(window, 'removeEventListener')
|
||||
const Comp = createTestComponent(closeFn)
|
||||
const wrapper = mount(Comp, { attachTo: document.body })
|
||||
|
||||
wrapper.unmount()
|
||||
|
||||
expect(removeSpy).toHaveBeenCalledWith('keydown', expect.any(Function), true)
|
||||
removeSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
79
neode-ui/src/composables/__tests__/useNavSounds.test.ts
Normal file
79
neode-ui/src/composables/__tests__/useNavSounds.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// Mock Audio globally
|
||||
class MockAudio {
|
||||
src = ''
|
||||
volume = 1
|
||||
play = vi.fn().mockResolvedValue(undefined)
|
||||
pause = vi.fn()
|
||||
currentTime = 0
|
||||
addEventListener = vi.fn()
|
||||
}
|
||||
|
||||
vi.stubGlobal('Audio', MockAudio)
|
||||
|
||||
// Mock AudioContext
|
||||
const mockOscillator = {
|
||||
type: 'sine',
|
||||
frequency: { setValueAtTime: vi.fn() },
|
||||
connect: vi.fn(),
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
}
|
||||
const mockGain = {
|
||||
gain: {
|
||||
setValueAtTime: vi.fn(),
|
||||
linearRampToValueAtTime: vi.fn(),
|
||||
exponentialRampToValueAtTime: vi.fn(),
|
||||
},
|
||||
connect: vi.fn(),
|
||||
}
|
||||
const mockAudioContext = {
|
||||
createOscillator: vi.fn().mockReturnValue(mockOscillator),
|
||||
createGain: vi.fn().mockReturnValue(mockGain),
|
||||
currentTime: 0,
|
||||
destination: {},
|
||||
}
|
||||
|
||||
vi.stubGlobal('AudioContext', vi.fn().mockImplementation(() => mockAudioContext))
|
||||
|
||||
import { playNavSound } from '../useNavSounds'
|
||||
|
||||
describe('playNavSound', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('is a function', () => {
|
||||
expect(playNavSound).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
it('plays move sound (default)', () => {
|
||||
playNavSound()
|
||||
// Should try to play a sound
|
||||
})
|
||||
|
||||
it('plays move sound explicitly', () => {
|
||||
playNavSound('move')
|
||||
})
|
||||
|
||||
it('plays select sound', () => {
|
||||
playNavSound('select')
|
||||
})
|
||||
|
||||
it('plays action sound', () => {
|
||||
playNavSound('action')
|
||||
})
|
||||
|
||||
it('plays back sound using AudioContext', () => {
|
||||
playNavSound('back')
|
||||
// Back uses Web Audio API synthesis
|
||||
})
|
||||
|
||||
it('does not throw for any sound type', () => {
|
||||
expect(() => playNavSound('move')).not.toThrow()
|
||||
expect(() => playNavSound('select')).not.toThrow()
|
||||
expect(() => playNavSound('action')).not.toThrow()
|
||||
expect(() => playNavSound('back')).not.toThrow()
|
||||
})
|
||||
})
|
||||
102
neode-ui/src/composables/__tests__/useOnboarding.test.ts
Normal file
102
neode-ui/src/composables/__tests__/useOnboarding.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
isOnboardingComplete: vi.fn(),
|
||||
completeOnboarding: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { isOnboardingComplete, completeOnboarding } from '../useOnboarding'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
const mockedRpc = vi.mocked(rpcClient)
|
||||
|
||||
describe('useOnboarding', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('isOnboardingComplete', () => {
|
||||
it('returns true when RPC says complete', async () => {
|
||||
mockedRpc.isOnboardingComplete.mockResolvedValue(true)
|
||||
const result = await isOnboardingComplete()
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when RPC says not complete', async () => {
|
||||
mockedRpc.isOnboardingComplete.mockResolvedValue(false)
|
||||
const result = await isOnboardingComplete()
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to localStorage when RPC fails with non-retryable error', async () => {
|
||||
mockedRpc.isOnboardingComplete.mockRejectedValue(new Error('Unknown error'))
|
||||
localStorage.setItem('neode_onboarding_complete', '1')
|
||||
const result = await isOnboardingComplete()
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false from localStorage fallback when not set', async () => {
|
||||
mockedRpc.isOnboardingComplete.mockRejectedValue(new Error('Unknown error'))
|
||||
const result = await isOnboardingComplete()
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('retries on 502 errors before falling back', async () => {
|
||||
mockedRpc.isOnboardingComplete
|
||||
.mockRejectedValueOnce(new Error('502 Bad Gateway'))
|
||||
.mockResolvedValueOnce(true)
|
||||
|
||||
const promise = isOnboardingComplete()
|
||||
await vi.advanceTimersByTimeAsync(900)
|
||||
const result = await promise
|
||||
expect(result).toBe(true)
|
||||
expect(mockedRpc.isOnboardingComplete).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('retries on 503 errors', async () => {
|
||||
mockedRpc.isOnboardingComplete
|
||||
.mockRejectedValueOnce(new Error('503 Service Unavailable'))
|
||||
.mockResolvedValueOnce(false)
|
||||
|
||||
const promise = isOnboardingComplete()
|
||||
await vi.advanceTimersByTimeAsync(900)
|
||||
const result = await promise
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to localStorage after exhausting retries', async () => {
|
||||
mockedRpc.isOnboardingComplete.mockRejectedValue(new Error('502 Bad Gateway'))
|
||||
localStorage.setItem('neode_onboarding_complete', '1')
|
||||
|
||||
const promise = isOnboardingComplete()
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
const result = await promise
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('completeOnboarding', () => {
|
||||
it('calls RPC and sets localStorage', async () => {
|
||||
mockedRpc.completeOnboarding.mockResolvedValue(true)
|
||||
await completeOnboarding()
|
||||
expect(mockedRpc.completeOnboarding).toHaveBeenCalled()
|
||||
expect(localStorage.getItem('neode_onboarding_complete')).toBe('1')
|
||||
})
|
||||
|
||||
it('sets localStorage even when RPC fails', async () => {
|
||||
mockedRpc.completeOnboarding.mockRejectedValue(new Error('Network error'))
|
||||
const promise = completeOnboarding()
|
||||
await vi.advanceTimersByTimeAsync(10000)
|
||||
await promise
|
||||
expect(localStorage.getItem('neode_onboarding_complete')).toBe('1')
|
||||
})
|
||||
})
|
||||
})
|
||||
131
neode-ui/src/composables/__tests__/useToast.test.ts
Normal file
131
neode-ui/src/composables/__tests__/useToast.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { useToast } from '../useToast'
|
||||
|
||||
describe('useToast', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
// Get a fresh toast instance and clear any leftover state
|
||||
const { toasts, dismiss } = useToast()
|
||||
// Dismiss all existing toasts
|
||||
for (const t of [...toasts.value]) {
|
||||
dismiss(t.id)
|
||||
}
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('creates a success toast', () => {
|
||||
const { success, toasts } = useToast()
|
||||
|
||||
success('Operation complete')
|
||||
|
||||
expect(toasts.value.length).toBeGreaterThanOrEqual(1)
|
||||
const toast = toasts.value[toasts.value.length - 1]!
|
||||
expect(toast.message).toBe('Operation complete')
|
||||
expect(toast.variant).toBe('success')
|
||||
expect(toast.dismissing).toBe(false)
|
||||
})
|
||||
|
||||
it('creates an error toast', () => {
|
||||
const { error, toasts } = useToast()
|
||||
|
||||
error('Something went wrong')
|
||||
|
||||
const toast = toasts.value[toasts.value.length - 1]!
|
||||
expect(toast.message).toBe('Something went wrong')
|
||||
expect(toast.variant).toBe('error')
|
||||
})
|
||||
|
||||
it('creates an info toast', () => {
|
||||
const { info, toasts } = useToast()
|
||||
|
||||
info('FYI: Node syncing')
|
||||
|
||||
const toast = toasts.value[toasts.value.length - 1]!
|
||||
expect(toast.message).toBe('FYI: Node syncing')
|
||||
expect(toast.variant).toBe('info')
|
||||
})
|
||||
|
||||
it('auto-dismisses toast after duration', () => {
|
||||
const { success, toasts } = useToast()
|
||||
|
||||
success('Will auto-dismiss')
|
||||
const toast = toasts.value[toasts.value.length - 1]!
|
||||
const toastId = toast.id
|
||||
|
||||
expect(toasts.value.some((t) => t.id === toastId)).toBe(true)
|
||||
|
||||
// After 3000ms, the toast should start dismissing
|
||||
vi.advanceTimersByTime(3000)
|
||||
|
||||
const dismissingToast = toasts.value.find((t) => t.id === toastId)
|
||||
if (dismissingToast) {
|
||||
expect(dismissingToast.dismissing).toBe(true)
|
||||
}
|
||||
|
||||
// After another 300ms, the toast should be fully removed
|
||||
vi.advanceTimersByTime(300)
|
||||
|
||||
expect(toasts.value.some((t) => t.id === toastId)).toBe(false)
|
||||
})
|
||||
|
||||
it('dismiss marks toast as dismissing then removes it', () => {
|
||||
const { info, toasts, dismiss } = useToast()
|
||||
|
||||
info('Dismissable')
|
||||
const toast = toasts.value[toasts.value.length - 1]!
|
||||
|
||||
dismiss(toast.id)
|
||||
|
||||
// Should be marked as dismissing
|
||||
const found = toasts.value.find((t) => t.id === toast.id)
|
||||
if (found) {
|
||||
expect(found.dismissing).toBe(true)
|
||||
}
|
||||
|
||||
// After 300ms animation delay, should be removed
|
||||
vi.advanceTimersByTime(300)
|
||||
|
||||
expect(toasts.value.some((t) => t.id === toast.id)).toBe(false)
|
||||
})
|
||||
|
||||
it('dismiss is a no-op for nonexistent toast ID', () => {
|
||||
const { dismiss, toasts } = useToast()
|
||||
const countBefore = toasts.value.length
|
||||
|
||||
dismiss(999999)
|
||||
|
||||
expect(toasts.value.length).toBe(countBefore)
|
||||
})
|
||||
|
||||
it('each toast gets a unique ID', () => {
|
||||
const { info, toasts } = useToast()
|
||||
|
||||
info('First')
|
||||
info('Second')
|
||||
info('Third')
|
||||
|
||||
const ids = toasts.value.slice(-3).map((t) => t.id)
|
||||
const uniqueIds = new Set(ids)
|
||||
expect(uniqueIds.size).toBe(3)
|
||||
})
|
||||
|
||||
it('caps visible toasts at 5', () => {
|
||||
const { info, toasts } = useToast()
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
info(`Toast ${i}`)
|
||||
}
|
||||
|
||||
expect(toasts.value.length).toBeLessThanOrEqual(5)
|
||||
})
|
||||
|
||||
it('toasts ref is readonly', () => {
|
||||
const { toasts } = useToast()
|
||||
// The readonly wrapper prevents direct mutation
|
||||
expect(typeof toasts.value).toBe('object')
|
||||
})
|
||||
})
|
||||
91
neode-ui/src/composables/useAudioPlayer.ts
Normal file
91
neode-ui/src/composables/useAudioPlayer.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const audio = ref<HTMLAudioElement | null>(null)
|
||||
const currentSrc = ref<string | null>(null)
|
||||
const currentName = ref('')
|
||||
const playing = ref(false)
|
||||
const currentTime = ref(0)
|
||||
const duration = ref(0)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
function play(src: string, name: string) {
|
||||
if (!audio.value) {
|
||||
audio.value = new Audio()
|
||||
audio.value.addEventListener('timeupdate', () => {
|
||||
currentTime.value = audio.value?.currentTime ?? 0
|
||||
})
|
||||
audio.value.addEventListener('loadedmetadata', () => {
|
||||
duration.value = audio.value?.duration ?? 0
|
||||
error.value = null
|
||||
})
|
||||
audio.value.addEventListener('ended', () => {
|
||||
playing.value = false
|
||||
})
|
||||
audio.value.addEventListener('pause', () => {
|
||||
playing.value = false
|
||||
})
|
||||
audio.value.addEventListener('play', () => {
|
||||
playing.value = true
|
||||
error.value = null
|
||||
})
|
||||
audio.value.addEventListener('error', () => {
|
||||
playing.value = false
|
||||
error.value = 'Could not play audio. File Browser may not be running.'
|
||||
})
|
||||
}
|
||||
error.value = null
|
||||
|
||||
if (currentSrc.value === src && playing.value) {
|
||||
audio.value.pause()
|
||||
return
|
||||
}
|
||||
|
||||
if (currentSrc.value !== src) {
|
||||
audio.value.src = src
|
||||
currentSrc.value = src
|
||||
currentName.value = name
|
||||
}
|
||||
|
||||
audio.value.play()
|
||||
}
|
||||
|
||||
function pause() {
|
||||
audio.value?.pause()
|
||||
}
|
||||
|
||||
function seek(time: number) {
|
||||
if (audio.value) {
|
||||
audio.value.currentTime = time
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (audio.value) {
|
||||
audio.value.pause()
|
||||
audio.value.currentTime = 0
|
||||
}
|
||||
playing.value = false
|
||||
currentSrc.value = null
|
||||
currentName.value = ''
|
||||
}
|
||||
|
||||
const progress = computed(() => {
|
||||
if (duration.value === 0) return 0
|
||||
return (currentTime.value / duration.value) * 100
|
||||
})
|
||||
|
||||
export function useAudioPlayer() {
|
||||
return {
|
||||
play,
|
||||
pause,
|
||||
seek,
|
||||
stop,
|
||||
playing,
|
||||
currentName,
|
||||
currentTime,
|
||||
duration,
|
||||
progress,
|
||||
currentSrc,
|
||||
error,
|
||||
}
|
||||
}
|
||||
468
neode-ui/src/composables/useControllerNav.ts
Normal file
468
neode-ui/src/composables/useControllerNav.ts
Normal file
@@ -0,0 +1,468 @@
|
||||
/**
|
||||
* Xbox-style controller / gamepad navigation for Archipelago.
|
||||
* - Left: Go to side menu only when on leftmost main content
|
||||
* - Right: Go to main content (from side menu)
|
||||
* - Main: spatial/grid navigation (up/down/left/right like a game)
|
||||
* - Enter enters container's inner actions; actions get celebratory sound
|
||||
*/
|
||||
|
||||
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useControllerStore } from '@/stores/controller'
|
||||
import { useSpotlightStore } from '@/stores/spotlight'
|
||||
import { useCLIStore } from '@/stores/cli'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
|
||||
const FOCUSABLE_SELECTOR = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
'input:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
'[data-controller-focus]',
|
||||
'[data-controller-container]',
|
||||
].join(', ')
|
||||
|
||||
function getFocusableElements(container: Document | HTMLElement = document): HTMLElement[] {
|
||||
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
|
||||
(el) =>
|
||||
!el.hasAttribute('disabled') &&
|
||||
el.offsetParent !== null &&
|
||||
!el.hasAttribute('data-controller-ignore') &&
|
||||
!el.closest('[data-controller-ignore]')
|
||||
)
|
||||
}
|
||||
|
||||
function getElementsInZone(zone: 'sidebar' | 'main'): HTMLElement[] {
|
||||
const container = document.querySelector(`[data-controller-zone="${zone}"]`) as HTMLElement | null
|
||||
if (!container) return []
|
||||
return getFocusableElements(container)
|
||||
}
|
||||
|
||||
function isInZone(el: HTMLElement | null, zone: 'sidebar' | 'main'): boolean {
|
||||
if (!el) return false
|
||||
return !!el.closest(`[data-controller-zone="${zone}"]`)
|
||||
}
|
||||
|
||||
function getInnerFocusables(container: HTMLElement): HTMLElement[] {
|
||||
return getFocusableElements(container).filter((el) => el !== container && !el.hasAttribute('data-controller-container'))
|
||||
}
|
||||
|
||||
function isInsideContainer(el: HTMLElement | null): boolean {
|
||||
if (!el) return false
|
||||
const container = el.closest('[data-controller-container]')
|
||||
return !!container && container !== el
|
||||
}
|
||||
|
||||
/** Spatial navigation: find nearest focusable in direction (game-style grid) */
|
||||
function findNearestInDirection(
|
||||
from: HTMLElement,
|
||||
candidates: HTMLElement[],
|
||||
direction: 'up' | 'down' | 'left' | 'right'
|
||||
): HTMLElement | null {
|
||||
const fromRect = from.getBoundingClientRect()
|
||||
const fromCenterX = fromRect.left + fromRect.width / 2
|
||||
const fromCenterY = fromRect.top + fromRect.height / 2
|
||||
const threshold = 50 // px overlap allowed
|
||||
|
||||
const filtered = candidates.filter((el) => {
|
||||
if (el === from) return false
|
||||
const r = el.getBoundingClientRect()
|
||||
|
||||
switch (direction) {
|
||||
case 'left':
|
||||
return r.right <= fromRect.left + threshold
|
||||
case 'right':
|
||||
return r.left >= fromRect.right - threshold
|
||||
case 'up':
|
||||
return r.bottom <= fromRect.top + threshold
|
||||
case 'down':
|
||||
return r.top >= fromRect.bottom - threshold
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
if (filtered.length === 0) return null
|
||||
|
||||
// Pick best: most overlap on perpendicular axis, then closest
|
||||
const scored = filtered.map((el) => {
|
||||
const r = el.getBoundingClientRect()
|
||||
const centerX = r.left + r.width / 2
|
||||
const centerY = r.top + r.height / 2
|
||||
|
||||
let overlap: number
|
||||
let dist: number
|
||||
switch (direction) {
|
||||
case 'left':
|
||||
case 'right':
|
||||
overlap = Math.max(0, Math.min(fromRect.bottom, r.bottom) - Math.max(fromRect.top, r.top))
|
||||
dist = Math.abs(centerX - fromCenterX)
|
||||
break
|
||||
case 'up':
|
||||
case 'down':
|
||||
overlap = Math.max(0, Math.min(fromRect.right, r.right) - Math.max(fromRect.left, r.left))
|
||||
dist = Math.abs(centerY - fromCenterY)
|
||||
break
|
||||
default:
|
||||
overlap = 0
|
||||
dist = Infinity
|
||||
}
|
||||
return { el, overlap, dist }
|
||||
})
|
||||
|
||||
scored.sort((a, b) => {
|
||||
if (b.overlap !== a.overlap) return b.overlap - a.overlap
|
||||
return a.dist - b.dist
|
||||
})
|
||||
return scored[0]?.el ?? null
|
||||
}
|
||||
|
||||
export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useControllerStore()
|
||||
const isControllerActive = ref(false)
|
||||
const gamepadCount = ref(0)
|
||||
|
||||
watch([isControllerActive, gamepadCount], () => {
|
||||
store.setActive(isControllerActive.value)
|
||||
store.setGamepadCount(gamepadCount.value)
|
||||
}, { immediate: true })
|
||||
let keyNavTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let pollIntervalId: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function checkGamepads() {
|
||||
const gamepads = navigator.getGamepads?.()
|
||||
const count = gamepads ? Array.from(gamepads).filter((g) => g?.connected).length : 0
|
||||
if (count !== gamepadCount.value) {
|
||||
gamepadCount.value = count
|
||||
isControllerActive.value = count > 0
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
const navKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape']
|
||||
if (!navKeys.includes(e.key)) return
|
||||
|
||||
const target = e.target as HTMLElement
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
||||
if (e.key !== 'Escape') return
|
||||
}
|
||||
|
||||
const root = containerRef?.value ?? document
|
||||
const focusable = getFocusableElements(root)
|
||||
const currentIndex = focusable.indexOf(document.activeElement as HTMLElement)
|
||||
const activeEl = document.activeElement as HTMLElement
|
||||
|
||||
// --- ESCAPE ---
|
||||
if (e.key === 'Escape') {
|
||||
if (useAppLauncherStore().isOpen) {
|
||||
useAppLauncherStore().close()
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return
|
||||
}
|
||||
if (useSpotlightStore().isOpen) {
|
||||
useSpotlightStore().close()
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return
|
||||
}
|
||||
if (useCLIStore().isOpen) {
|
||||
useCLIStore().close()
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return
|
||||
}
|
||||
|
||||
if (isInsideContainer(activeEl)) {
|
||||
const container = activeEl.closest('[data-controller-container]') as HTMLElement | null
|
||||
if (container && container.tabIndex >= 0) {
|
||||
playNavSound('back')
|
||||
container.focus()
|
||||
container.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const isDetailPage = /\/apps\/[^/]+$|\/marketplace\/[^/]+$|\/cloud\/[^/]+$/.test(route.path)
|
||||
if (isDetailPage) {
|
||||
playNavSound('back')
|
||||
window.history.back()
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
const sidebarEls = getElementsInZone('sidebar')
|
||||
const firstSidebar = sidebarEls[0]
|
||||
if (firstSidebar && isInZone(activeEl, 'main')) {
|
||||
playNavSound('back')
|
||||
firstSidebar.focus()
|
||||
firstSidebar.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
playNavSound('back')
|
||||
window.history.back()
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
// --- ENTER ---
|
||||
if (e.key === 'Enter') {
|
||||
if (currentIndex >= 0 && focusable[currentIndex]) {
|
||||
const el = focusable[currentIndex] as HTMLElement
|
||||
|
||||
if (el.hasAttribute('data-controller-container')) {
|
||||
// Marketplace: Enter = install (click install button)
|
||||
if (el.hasAttribute('data-controller-install')) {
|
||||
const installBtn = el.querySelector<HTMLButtonElement>('[data-controller-install-btn]:not([disabled])')
|
||||
if (installBtn) {
|
||||
playNavSound('action')
|
||||
installBtn.click()
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
// My Apps: Enter = launch (click Launch button when app is runnable)
|
||||
if (el.hasAttribute('data-controller-launch')) {
|
||||
const launchBtn = el.querySelector<HTMLButtonElement>('[data-controller-launch-btn]:not([disabled])')
|
||||
if (launchBtn) {
|
||||
playNavSound('action')
|
||||
launchBtn.click()
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
// My Apps, etc: Enter = focus first inner control
|
||||
const inner = getInnerFocusables(el)
|
||||
const firstInner = inner[0]
|
||||
if (firstInner) {
|
||||
playNavSound('action')
|
||||
firstInner.focus()
|
||||
firstInner.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
playNavSound('action')
|
||||
el.click()
|
||||
}
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
// --- ARROWS ---
|
||||
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
||||
isControllerActive.value = true
|
||||
if (keyNavTimeout) clearTimeout(keyNavTimeout)
|
||||
keyNavTimeout = setTimeout(() => {
|
||||
isControllerActive.value = gamepadCount.value > 0
|
||||
}, 3000)
|
||||
|
||||
const sidebarEls = getElementsInZone('sidebar')
|
||||
const mainEls = getElementsInZone('main')
|
||||
const hasZones = sidebarEls.length > 0 && mainEls.length > 0
|
||||
|
||||
// Right: from sidebar → main
|
||||
// - On Home: go to My Apps container
|
||||
// - On Apps/Marketplace: go to first app container
|
||||
// - On Cloud: go to first folder (Pictures)
|
||||
// - On Network (server): go to Services container
|
||||
// - On Web5: go to Networking Profits container
|
||||
// - On Settings: go to Change Password container
|
||||
// - Otherwise: go to top right (App Switcher)
|
||||
const mainZone = document.querySelector('[data-controller-zone="main"]')
|
||||
const isHome = /^\/dashboard(\/)?$/.test(route.path)
|
||||
const isAppsOrMarketplace = /^\/dashboard\/(apps|marketplace)(\/|$)/.test(route.path)
|
||||
const isCloud = /^\/dashboard\/cloud(\/|$)/.test(route.path)
|
||||
const isNetwork = /^\/dashboard\/server(\/|$)/.test(route.path)
|
||||
const isWeb5 = /^\/dashboard\/web5(\/|$)/.test(route.path)
|
||||
const isSettings = /^\/dashboard\/settings(\/|$)/.test(route.path)
|
||||
const firstAppContainer = mainZone?.querySelector<HTMLElement>('[data-controller-container]')
|
||||
const topRightEntry = mainZone?.querySelector<HTMLElement>('[data-controller-main-entry]')
|
||||
const firstFocusableInTopRight = topRightEntry ? getFocusableElements(topRightEntry)[0] : null
|
||||
const firstMain = ((isHome || isAppsOrMarketplace || isCloud || isNetwork || isWeb5 || isSettings) && firstAppContainer)
|
||||
? firstAppContainer
|
||||
: (firstFocusableInTopRight ?? mainEls[0])
|
||||
if (e.key === 'ArrowRight' && hasZones && isInZone(activeEl, 'sidebar') && firstMain) {
|
||||
playNavSound('move')
|
||||
firstMain.focus()
|
||||
firstMain.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
// Main zone: spatial navigation (game-style grid)
|
||||
if (hasZones && isInZone(activeEl, 'main')) {
|
||||
const dir = e.key === 'ArrowLeft' ? 'left' : e.key === 'ArrowRight' ? 'right' : e.key === 'ArrowUp' ? 'up' : 'down'
|
||||
const next = findNearestInDirection(activeEl, mainEls, dir)
|
||||
|
||||
if (next) {
|
||||
playNavSound('move')
|
||||
next.focus()
|
||||
next.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
// No element in that direction: Left from leftmost → sidebar (focus active tab, not logout)
|
||||
if (e.key === 'ArrowLeft' && dir === 'left') {
|
||||
const sidebarZone = document.querySelector('[data-controller-zone="sidebar"]')
|
||||
const activeNavTab = sidebarZone?.querySelector<HTMLElement>('.nav-tab-active')
|
||||
const target = activeNavTab ?? sidebarEls[0]
|
||||
if (target) {
|
||||
playNavSound('move')
|
||||
target.focus()
|
||||
target.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inside container: spatial nav among inner elements
|
||||
if (isInsideContainer(activeEl)) {
|
||||
const container = activeEl.closest('[data-controller-container]') as HTMLElement
|
||||
if (container) {
|
||||
const inner = getInnerFocusables(container)
|
||||
const dir = e.key === 'ArrowLeft' ? 'left' : e.key === 'ArrowRight' ? 'right' : e.key === 'ArrowUp' ? 'up' : 'down'
|
||||
const next = findNearestInDirection(activeEl, inner, dir)
|
||||
if (next) {
|
||||
playNavSound('move')
|
||||
next.focus()
|
||||
next.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sidebar: linear up/down with wrap (Home+Up→Logout, Logout+Down→Home)
|
||||
if (isInZone(activeEl, 'sidebar')) {
|
||||
const idx = sidebarEls.indexOf(activeEl)
|
||||
if (idx >= 0) {
|
||||
const isDown = e.key === 'ArrowDown'
|
||||
let nextIdx: number
|
||||
if (isDown) {
|
||||
nextIdx = idx >= sidebarEls.length - 1 ? 0 : idx + 1
|
||||
} else {
|
||||
nextIdx = idx <= 0 ? sidebarEls.length - 1 : idx - 1
|
||||
}
|
||||
const next = sidebarEls[nextIdx]
|
||||
if (next && next !== activeEl) {
|
||||
playNavSound('move')
|
||||
next.focus()
|
||||
next.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
if (next.tagName === 'A') {
|
||||
const href = (next as HTMLAnchorElement).getAttribute?.('href')
|
||||
if (href && href.startsWith('/')) router.push(href).catch(() => {})
|
||||
}
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: linear navigation
|
||||
let nextIndex = currentIndex
|
||||
const isForward = e.key === 'ArrowDown' || e.key === 'ArrowRight'
|
||||
if (focusable.length === 0) return
|
||||
|
||||
if (currentIndex < 0) {
|
||||
nextIndex = isForward ? 0 : focusable.length - 1
|
||||
} else {
|
||||
nextIndex = isForward ? currentIndex + 1 : currentIndex - 1
|
||||
if (nextIndex < 0) nextIndex = focusable.length - 1
|
||||
if (nextIndex >= focusable.length) nextIndex = 0
|
||||
}
|
||||
|
||||
const next = focusable[nextIndex]
|
||||
if (next) {
|
||||
playNavSound('move')
|
||||
next.focus()
|
||||
next.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
if (isInZone(next, 'sidebar') && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
|
||||
const href = (next as HTMLAnchorElement).getAttribute?.('href')
|
||||
if (href && href.startsWith('/') && next.tagName === 'A') {
|
||||
router.push(href).catch(() => {})
|
||||
}
|
||||
}
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleGamepadInput() {
|
||||
checkGamepads()
|
||||
}
|
||||
|
||||
function handleGamepadConnected() {
|
||||
const gamepads = navigator.getGamepads?.()
|
||||
gamepadCount.value = gamepads ? Array.from(gamepads).filter((g) => g?.connected).length : 1
|
||||
isControllerActive.value = true
|
||||
}
|
||||
|
||||
function handleGamepadDisconnected() {
|
||||
const gamepads = navigator.getGamepads?.()
|
||||
gamepadCount.value = gamepads ? Array.from(gamepads).filter((g) => g?.connected).length : 0
|
||||
isControllerActive.value = gamepadCount.value > 0
|
||||
}
|
||||
|
||||
/** Find nearest scrollable ancestor (overflow-y auto/scroll) */
|
||||
function getScrollableAncestor(el: HTMLElement | null): HTMLElement | null {
|
||||
let p = el?.parentElement
|
||||
while (p) {
|
||||
const style = getComputedStyle(p)
|
||||
const oy = style.overflowY
|
||||
if ((oy === 'auto' || oy === 'scroll') && p.scrollHeight > p.clientHeight) return p
|
||||
p = p.parentElement
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Ensure wheel scrolls the scrollable area containing the focused element */
|
||||
function handleWheel(e: WheelEvent) {
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
if (!active) return
|
||||
const scrollable = getScrollableAncestor(active)
|
||||
if (!scrollable) return
|
||||
if (e.deltaY !== 0) {
|
||||
scrollable.scrollTop += e.deltaY
|
||||
e.preventDefault()
|
||||
}
|
||||
if (e.deltaX !== 0 && scrollable.scrollWidth > scrollable.clientWidth) {
|
||||
scrollable.scrollLeft += e.deltaX
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkGamepads()
|
||||
window.addEventListener('keydown', handleKeyDown, true)
|
||||
window.addEventListener('wheel', handleWheel, { passive: false })
|
||||
window.addEventListener('gamepadconnected', handleGamepadConnected)
|
||||
window.addEventListener('gamepaddisconnected', handleGamepadDisconnected)
|
||||
pollIntervalId = setInterval(handleGamepadInput, 500)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', handleKeyDown, true)
|
||||
window.removeEventListener('wheel', handleWheel)
|
||||
window.removeEventListener('gamepadconnected', handleGamepadConnected)
|
||||
window.removeEventListener('gamepaddisconnected', handleGamepadDisconnected)
|
||||
if (pollIntervalId) clearInterval(pollIntervalId)
|
||||
if (keyNavTimeout) clearTimeout(keyNavTimeout)
|
||||
})
|
||||
|
||||
return {
|
||||
isControllerActive,
|
||||
gamepadCount,
|
||||
}
|
||||
}
|
||||
99
neode-ui/src/composables/useFileType.ts
Normal file
99
neode-ui/src/composables/useFileType.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { computed, type Ref } from 'vue'
|
||||
|
||||
const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico'])
|
||||
const AUDIO_EXTS = new Set(['mp3', 'flac', 'wav', 'ogg', 'aac', 'm4a', 'wma'])
|
||||
const VIDEO_EXTS = new Set(['mp4', 'mkv', 'avi', 'mov', 'webm', 'wmv', 'flv'])
|
||||
const DOC_EXTS = new Set(['pdf', 'doc', 'docx', 'txt', 'rtf', 'odt', 'md'])
|
||||
const SHEET_EXTS = new Set(['xls', 'xlsx', 'csv', 'ods'])
|
||||
const ARCHIVE_EXTS = new Set(['zip', 'tar', 'gz', 'rar', '7z', 'bz2'])
|
||||
|
||||
export type FileCategory = 'folder' | 'image' | 'audio' | 'video' | 'document' | 'spreadsheet' | 'archive' | 'file'
|
||||
|
||||
export function getFileCategory(ext: string, isDir: boolean): FileCategory {
|
||||
if (isDir) return 'folder'
|
||||
if (IMAGE_EXTS.has(ext)) return 'image'
|
||||
if (AUDIO_EXTS.has(ext)) return 'audio'
|
||||
if (VIDEO_EXTS.has(ext)) return 'video'
|
||||
if (DOC_EXTS.has(ext)) return 'document'
|
||||
if (SHEET_EXTS.has(ext)) return 'spreadsheet'
|
||||
if (ARCHIVE_EXTS.has(ext)) return 'archive'
|
||||
return 'file'
|
||||
}
|
||||
|
||||
const CATEGORY_ICONS: Record<FileCategory, string[]> = {
|
||||
folder: ['M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z'],
|
||||
audio: ['M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3'],
|
||||
video: ['M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z', 'M21 12a9 9 0 11-18 0 9 9 0 0118 0z'],
|
||||
image: ['M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z'],
|
||||
document: ['M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'],
|
||||
spreadsheet: ['M3 10h18M3 14h18M3 6h18M3 18h18M8 6v12M16 6v12'],
|
||||
archive: ['M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4'],
|
||||
file: ['M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z'],
|
||||
}
|
||||
|
||||
const CATEGORY_COLORS: Record<FileCategory, string> = {
|
||||
folder: 'text-amber-400',
|
||||
audio: 'text-orange-400',
|
||||
video: 'text-purple-400',
|
||||
image: 'text-blue-400',
|
||||
document: 'text-green-400',
|
||||
spreadsheet: 'text-emerald-400',
|
||||
archive: 'text-yellow-400',
|
||||
file: 'text-white/50',
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<FileCategory, string> = {
|
||||
folder: 'Folder',
|
||||
audio: 'Audio',
|
||||
video: 'Video',
|
||||
image: 'Image',
|
||||
document: 'Document',
|
||||
spreadsheet: 'Spreadsheet',
|
||||
archive: 'Archive',
|
||||
file: 'File',
|
||||
}
|
||||
|
||||
const CATEGORY_BADGE_CLASSES: Record<FileCategory, string> = {
|
||||
folder: 'bg-amber-500/15 text-amber-400/70',
|
||||
audio: 'bg-orange-500/15 text-orange-400/70',
|
||||
video: 'bg-purple-500/15 text-purple-400/70',
|
||||
image: 'bg-blue-500/15 text-blue-400/70',
|
||||
document: 'bg-green-500/15 text-green-400/70',
|
||||
spreadsheet: 'bg-emerald-500/15 text-emerald-400/70',
|
||||
archive: 'bg-yellow-500/15 text-yellow-400/70',
|
||||
file: 'bg-white/8 text-white/50',
|
||||
}
|
||||
|
||||
export function useFileType(ext: Ref<string>, isDir: Ref<boolean>) {
|
||||
const category = computed(() => getFileCategory(ext.value, isDir.value))
|
||||
const isImage = computed(() => category.value === 'image')
|
||||
const isAudio = computed(() => category.value === 'audio')
|
||||
const isVideo = computed(() => category.value === 'video')
|
||||
const iconPaths = computed(() => CATEGORY_ICONS[category.value])
|
||||
const iconColor = computed(() => CATEGORY_COLORS[category.value])
|
||||
const badgeLabel = computed(() => CATEGORY_LABELS[category.value])
|
||||
const badgeClass = computed(() => CATEGORY_BADGE_CLASSES[category.value])
|
||||
|
||||
return { category, isImage, isAudio, isVideo, iconPaths, iconColor, badgeLabel, badgeClass }
|
||||
}
|
||||
|
||||
export function formatSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)} ${units[i]}`
|
||||
}
|
||||
|
||||
export function formatDate(iso: string): string {
|
||||
const date = new Date(iso)
|
||||
const now = Date.now()
|
||||
const diff = now - date.getTime()
|
||||
const mins = Math.floor(diff / 60000)
|
||||
if (mins < 1) return 'Just now'
|
||||
if (mins < 60) return `${mins}m ago`
|
||||
const hours = Math.floor(mins / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 7) return `${days}d ago`
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
262
neode-ui/src/composables/useLoginSounds.ts
Normal file
262
neode-ui/src/composables/useLoginSounds.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Login screen audio: intro loop (MP3) + transition sounds.
|
||||
*/
|
||||
|
||||
let audioContext: AudioContext | null = null
|
||||
let introAudio: HTMLAudioElement | null = null
|
||||
let introGain: GainNode | null = null
|
||||
|
||||
/** Get AudioContext - only returns existing. Create via resumeAudioContext() after user gesture. */
|
||||
function getContext(): AudioContext | null {
|
||||
return audioContext
|
||||
}
|
||||
|
||||
/** Create AudioContext if needed (call only from user gesture - click/tap/key) */
|
||||
function ensureContext(): AudioContext | null {
|
||||
if (audioContext) return audioContext
|
||||
try {
|
||||
const Ctx = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext
|
||||
if (!Ctx) return null
|
||||
audioContext = new Ctx()
|
||||
return audioContext
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const INTRO_AUDIO_URL = '/assets/audio/cosmic-updrift.mp3'
|
||||
const LOOP_START_URL = '/assets/audio/loop-start.mp3'
|
||||
|
||||
/** Play loop-start when transitioning from typing intro to Welcome Noderunner, as the intro music comes in.
|
||||
* Uses Web Audio API so it plays after context is resumed (user gesture). */
|
||||
export function playLoopStart() {
|
||||
const ctx = getContext()
|
||||
if (!ctx) return
|
||||
try {
|
||||
if (ctx.state === 'suspended') ctx.resume()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
fetch(LOOP_START_URL)
|
||||
.then((res) => res.arrayBuffer())
|
||||
.then((buf) => ctx.decodeAudioData(buf))
|
||||
.then((decoded) => {
|
||||
const src = ctx.createBufferSource()
|
||||
src.buffer = decoded
|
||||
const gain = ctx.createGain()
|
||||
gain.gain.value = 0.5
|
||||
src.connect(gain)
|
||||
gain.connect(ctx.destination)
|
||||
src.start(0)
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
/** Resume audio context - MUST be called from user gesture (click/tap/key). Creates context if needed. */
|
||||
export function resumeAudioContext() {
|
||||
const ctx = ensureContext()
|
||||
if (ctx?.state === 'suspended') {
|
||||
ctx.resume().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
/** Start intro loop - Cosmic Updrift. Only works after resumeAudioContext() (user gesture). */
|
||||
export function startSynthwave() {
|
||||
const ctxOrNull = getContext()
|
||||
if (!ctxOrNull) return
|
||||
|
||||
try {
|
||||
if (ctxOrNull.state === 'suspended') ctxOrNull.resume().catch(() => {})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
stopSynthwave()
|
||||
|
||||
const audio = new Audio(INTRO_AUDIO_URL)
|
||||
audio.loop = true
|
||||
|
||||
const ctx = ctxOrNull
|
||||
const source = ctx.createMediaElementSource(audio)
|
||||
const gainNode = ctx.createGain()
|
||||
gainNode.gain.value = 0.25
|
||||
source.connect(gainNode)
|
||||
gainNode.connect(ctx.destination)
|
||||
introGain = gainNode
|
||||
introAudio = audio
|
||||
|
||||
audio.play().catch(() => {})
|
||||
}
|
||||
|
||||
/** Stop intro loop (call on login success) */
|
||||
export function stopSynthwave() {
|
||||
if (introAudio) {
|
||||
if (introGain && audioContext) {
|
||||
const t = audioContext.currentTime
|
||||
introGain.gain.setValueAtTime(introGain.gain.value, t)
|
||||
introGain.gain.linearRampToValueAtTime(0.001, t + 0.2)
|
||||
}
|
||||
setTimeout(() => {
|
||||
introAudio?.pause()
|
||||
introAudio = null
|
||||
introGain = null
|
||||
}, 220)
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop ALL login audio and close AudioContext. Call on route change to dashboard. */
|
||||
export function stopAllAudio() {
|
||||
// Stop synthwave loop
|
||||
if (introAudio) {
|
||||
introAudio.pause()
|
||||
introAudio = null
|
||||
introGain = null
|
||||
}
|
||||
// Stop intro typing
|
||||
stopIntroTyping()
|
||||
// Close AudioContext to kill any lingering BufferSource nodes (playLoopStart)
|
||||
if (audioContext) {
|
||||
audioContext.close().catch(() => {})
|
||||
audioContext = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Pop sound - plays when intro initiator (tap to start) is pressed */
|
||||
export function playPop() {
|
||||
const audio = new Audio('/assets/audio/pop.mp3')
|
||||
audio.volume = 0.6
|
||||
audio.play().catch(() => {})
|
||||
}
|
||||
|
||||
/** Whoosh transition on successful login */
|
||||
export function playLoginSuccessWhoosh() {
|
||||
const woosh = new Audio('/assets/audio/woosh.mp3')
|
||||
woosh.volume = 0.5
|
||||
woosh.play().catch(() => {})
|
||||
}
|
||||
|
||||
/** Typing sound - plays once when welcome typing starts (typing.mp3) */
|
||||
export function playTypingSound() {
|
||||
const audio = new Audio('/assets/audio/typing.mp3')
|
||||
audio.volume = 0.6
|
||||
audio.play().catch(() => {})
|
||||
}
|
||||
|
||||
/** Intro typing - ONE sound per sentence: play when sentence starts, stop when it ends (intro-typing.mp3 from archy assets) */
|
||||
let introTypingAudio: HTMLAudioElement | null = null
|
||||
const INTRO_TYPING_URL = '/assets/audio/intro-typing.mp3'
|
||||
|
||||
export function playIntroTyping() {
|
||||
stopIntroTyping()
|
||||
introTypingAudio = new Audio(INTRO_TYPING_URL)
|
||||
introTypingAudio.volume = 0.5
|
||||
introTypingAudio.loop = true
|
||||
introTypingAudio.play().catch(() => {})
|
||||
}
|
||||
|
||||
export function stopIntroTyping() {
|
||||
if (introTypingAudio) {
|
||||
introTypingAudio.pause()
|
||||
introTypingAudio.currentTime = 0
|
||||
introTypingAudio = null
|
||||
}
|
||||
}
|
||||
|
||||
const WELCOME_SPEECH_URL = '/assets/audio/welcome-noderunner.mp3'
|
||||
|
||||
/** Sci-fi female voice: "Welcome Noderunner" - plays when welcome text types in.
|
||||
* Requires pre-recorded audio from ElevenLabs. Run:
|
||||
* ELEVENLABS_API_KEY=your_key node neode-ui/scripts/generate-welcome-speech.js
|
||||
* Browse sci-fi voices at elevenlabs.io/voice-library and set ELEVENLABS_VOICE_ID for custom voice. */
|
||||
export function playWelcomeNoderunnerSpeech() {
|
||||
const audio = new Audio(WELCOME_SPEECH_URL)
|
||||
audio.volume = 0.9
|
||||
audio.play().catch(() => {})
|
||||
}
|
||||
|
||||
/** Typing tick - for dashboard welcome typing (typing.mp3) */
|
||||
let typingTickPool: HTMLAudioElement[] = []
|
||||
const TYPING_TICK_POOL_SIZE = 5
|
||||
|
||||
function getTypingTick(): HTMLAudioElement {
|
||||
if (typingTickPool.length === 0) {
|
||||
for (let i = 0; i < TYPING_TICK_POOL_SIZE; i++) {
|
||||
const a = new Audio('/assets/audio/typing.mp3')
|
||||
a.volume = 0.4
|
||||
typingTickPool.push(a)
|
||||
}
|
||||
}
|
||||
const a = typingTickPool.shift()!
|
||||
typingTickPool.push(a)
|
||||
return a
|
||||
}
|
||||
|
||||
export function playTypingTick() {
|
||||
const a = getTypingTick()
|
||||
a.currentTime = 0
|
||||
a.play().catch(() => {})
|
||||
}
|
||||
|
||||
/** Keyboard input sound - short synthesized click per key. Does NOT use typing.mp3 or intro-typing.mp3. */
|
||||
export function playKeyboardTypingSound() {
|
||||
const ctx = getContext()
|
||||
if (!ctx) return
|
||||
|
||||
try {
|
||||
if (ctx.state === 'suspended') ctx.resume()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const t = ctx.currentTime
|
||||
const osc = ctx.createOscillator()
|
||||
const gain = ctx.createGain()
|
||||
|
||||
osc.type = 'sine'
|
||||
osc.frequency.setValueAtTime(1200, t)
|
||||
gain.gain.setValueAtTime(0, t)
|
||||
gain.gain.linearRampToValueAtTime(0.06, t + 0.002)
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.04)
|
||||
|
||||
osc.connect(gain)
|
||||
gain.connect(ctx.destination)
|
||||
osc.start(t)
|
||||
osc.stop(t + 0.04)
|
||||
}
|
||||
|
||||
/** Gaming-style boot thud - soft impact when dashboard loads */
|
||||
export function playDashboardLoadOomph() {
|
||||
const ctx = getContext()
|
||||
if (!ctx) return
|
||||
|
||||
try {
|
||||
if (ctx.state === 'suspended') ctx.resume()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const t = ctx.currentTime
|
||||
|
||||
// Soft layered thud - sine only, smooth attack/decay
|
||||
const layers = [
|
||||
{ freq: 55, dur: 0.35, gain: 0.4, attack: 0.02 },
|
||||
{ freq: 82, dur: 0.28, gain: 0.25, attack: 0.03 },
|
||||
{ freq: 110, dur: 0.22, gain: 0.15, attack: 0.04 },
|
||||
{ freq: 165, dur: 0.18, gain: 0.1, attack: 0.05 },
|
||||
]
|
||||
|
||||
for (let i = 0; i < layers.length; i++) {
|
||||
const L = layers[i]!
|
||||
const osc = ctx.createOscillator()
|
||||
const g = ctx.createGain()
|
||||
osc.type = 'sine'
|
||||
osc.frequency.value = L.freq
|
||||
g.gain.setValueAtTime(0, t)
|
||||
g.gain.linearRampToValueAtTime(L.gain, t + L.attack)
|
||||
g.gain.exponentialRampToValueAtTime(0.001, t + L.dur)
|
||||
osc.connect(g)
|
||||
g.connect(ctx.destination)
|
||||
osc.start(t + i * 0.01)
|
||||
osc.stop(t + L.dur)
|
||||
}
|
||||
}
|
||||
58
neode-ui/src/composables/useMarketplaceApp.ts
Normal file
58
neode-ui/src/composables/useMarketplaceApp.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface MarketplaceAppInfo {
|
||||
id: string
|
||||
title: string
|
||||
version: string
|
||||
icon: string
|
||||
category: string
|
||||
description: string | { short: string; long: string }
|
||||
author: string
|
||||
source: string
|
||||
manifestUrl: string
|
||||
url: string
|
||||
repoUrl: string
|
||||
s9pkUrl: string
|
||||
dockerImage: string
|
||||
/** External web URL for iframe-based web apps (no container needed) */
|
||||
webUrl?: string
|
||||
}
|
||||
|
||||
// Simple in-memory store for the current marketplace app
|
||||
const currentMarketplaceApp = ref<MarketplaceAppInfo | null>(null)
|
||||
|
||||
export function useMarketplaceApp() {
|
||||
function setCurrentApp(app: Partial<MarketplaceAppInfo> & { id: string }) {
|
||||
// Create a clean, serializable copy
|
||||
currentMarketplaceApp.value = {
|
||||
id: app.id,
|
||||
title: app.title ?? '',
|
||||
version: app.version ?? '',
|
||||
icon: app.icon ?? '',
|
||||
category: app.category ?? '',
|
||||
description: app.description ?? '',
|
||||
author: app.author ?? '',
|
||||
source: app.source ?? '',
|
||||
manifestUrl: app.manifestUrl || app.s9pkUrl || app.url || '',
|
||||
url: app.url || app.s9pkUrl || app.manifestUrl || '',
|
||||
repoUrl: app.repoUrl ?? '',
|
||||
s9pkUrl: app.s9pkUrl ?? '',
|
||||
dockerImage: app.dockerImage ?? '',
|
||||
webUrl: (app as Record<string, unknown>).webUrl as string | undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentApp() {
|
||||
return currentMarketplaceApp.value
|
||||
}
|
||||
|
||||
function clearCurrentApp() {
|
||||
currentMarketplaceApp.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
setCurrentApp,
|
||||
getCurrentApp,
|
||||
clearCurrentApp
|
||||
}
|
||||
}
|
||||
92
neode-ui/src/composables/useMessageToast.ts
Normal file
92
neode-ui/src/composables/useMessageToast.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
export interface ReceivedMessage {
|
||||
from_pubkey: string
|
||||
message: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
const MESSAGE_POLL_INTERVAL = 30000 // 30s
|
||||
|
||||
// Shared state (singleton) so toast works across route changes
|
||||
const receivedMessages = ref<ReceivedMessage[]>([])
|
||||
const lastMessageCount = ref(0)
|
||||
const loadingMessages = ref(false)
|
||||
const toastMessage = ref<{ show: boolean; text: string }>({ show: false, text: '' })
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
export function useMessageToast() {
|
||||
const router = useRouter()
|
||||
|
||||
const unreadCount = computed(() =>
|
||||
Math.max(0, receivedMessages.value.length - lastMessageCount.value)
|
||||
)
|
||||
|
||||
async function loadReceivedMessages() {
|
||||
loadingMessages.value = true
|
||||
try {
|
||||
const res = await rpcClient.getReceivedMessages()
|
||||
const msgs = (res.messages || []) as ReceivedMessage[]
|
||||
receivedMessages.value = msgs
|
||||
// New messages since last check? (don't show toast on initial load)
|
||||
if (msgs.length > lastMessageCount.value && lastMessageCount.value > 0) {
|
||||
const newCount = msgs.length - lastMessageCount.value
|
||||
const latest = msgs[msgs.length - 1]
|
||||
toastMessage.value = {
|
||||
show: true,
|
||||
text: (newCount === 1 ? latest?.message : null) ?? `${newCount} new messages`,
|
||||
}
|
||||
lastMessageCount.value = msgs.length
|
||||
} else {
|
||||
lastMessageCount.value = msgs.length
|
||||
}
|
||||
} catch (e) {
|
||||
// Stop polling on auth failure — session expired, no point retrying
|
||||
if (e instanceof Error && /401|Unauthorized/i.test(e.message)) {
|
||||
stopPolling()
|
||||
return
|
||||
}
|
||||
if (import.meta.env.DEV) console.error('Failed to load messages:', e)
|
||||
} finally {
|
||||
loadingMessages.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (pollTimer) return
|
||||
loadReceivedMessages()
|
||||
pollTimer = setInterval(loadReceivedMessages, MESSAGE_POLL_INTERVAL)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function markAsRead() {
|
||||
lastMessageCount.value = receivedMessages.value.length
|
||||
}
|
||||
|
||||
function dismissToastAndOpenMessages() {
|
||||
toastMessage.value = { show: false, text: '' }
|
||||
markAsRead()
|
||||
router.push({ path: '/dashboard/web5', query: { tab: 'messages' } })
|
||||
}
|
||||
|
||||
return {
|
||||
receivedMessages,
|
||||
lastMessageCount,
|
||||
loadingMessages,
|
||||
toastMessage,
|
||||
unreadCount,
|
||||
loadReceivedMessages,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
markAsRead,
|
||||
dismissToastAndOpenMessages,
|
||||
}
|
||||
}
|
||||
103
neode-ui/src/composables/useMobileBackButton.ts
Normal file
103
neode-ui/src/composables/useMobileBackButton.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
/**
|
||||
* Composable for mobile back button positioning
|
||||
* Ensures back buttons are always 8px above the mobile tab bar
|
||||
* Uses ResizeObserver to reactively update when tab bar height changes
|
||||
*/
|
||||
export function useMobileBackButton() {
|
||||
const tabBarHeight = ref<number>(72) // Default fallback height
|
||||
|
||||
// Computed property for bottom position - always 16px above tab bar
|
||||
const bottomPosition = computed(() => {
|
||||
return `${tabBarHeight.value + 8}px`
|
||||
})
|
||||
|
||||
// Computed property for Tailwind class (for use in class bindings)
|
||||
const bottomClass = computed(() => {
|
||||
// Use Tailwind arbitrary value with the computed height
|
||||
return `bottom-[${tabBarHeight.value + 8}px]`
|
||||
})
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let mutationObserver: MutationObserver | null = null
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function updateTabBarHeight() {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
// Try to find the mobile tab bar element
|
||||
const tabBar = document.querySelector('[data-mobile-tab-bar]') as HTMLElement
|
||||
if (tabBar && tabBar.offsetHeight > 0) {
|
||||
tabBarHeight.value = tabBar.offsetHeight
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback: read from CSS variable if available
|
||||
const cssVar = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--mobile-tab-bar-height')
|
||||
.trim()
|
||||
|
||||
if (cssVar) {
|
||||
const height = parseFloat(cssVar)
|
||||
if (!isNaN(height) && height > 0) {
|
||||
tabBarHeight.value = height
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback: keep current value (don't reset to 0)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Initial update
|
||||
updateTabBarHeight()
|
||||
|
||||
// Watch for CSS variable changes
|
||||
mutationObserver = new MutationObserver(() => {
|
||||
updateTabBarHeight()
|
||||
})
|
||||
|
||||
mutationObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style'],
|
||||
})
|
||||
|
||||
// Watch for tab bar size changes
|
||||
const tabBar = document.querySelector('[data-mobile-tab-bar]') as HTMLElement
|
||||
if (tabBar && 'ResizeObserver' in window) {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
updateTabBarHeight()
|
||||
})
|
||||
resizeObserver.observe(tabBar)
|
||||
}
|
||||
|
||||
// Also listen to window resize as fallback
|
||||
window.addEventListener('resize', updateTabBarHeight)
|
||||
|
||||
// Periodic check to ensure we're always in sync (safety net)
|
||||
intervalId = setInterval(() => {
|
||||
updateTabBarHeight()
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (mutationObserver) {
|
||||
mutationObserver.disconnect()
|
||||
}
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
}
|
||||
window.removeEventListener('resize', updateTabBarHeight)
|
||||
})
|
||||
|
||||
return {
|
||||
bottomPosition,
|
||||
bottomClass,
|
||||
tabBarHeight,
|
||||
}
|
||||
}
|
||||
|
||||
74
neode-ui/src/composables/useModalKeyboard.ts
Normal file
74
neode-ui/src/composables/useModalKeyboard.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Modal keyboard navigation: Escape to close, Arrow keys to move between buttons.
|
||||
* Restores focus to the previously active element when closing via Escape.
|
||||
*/
|
||||
|
||||
import { onMounted, onBeforeUnmount, watch, type Ref } from 'vue'
|
||||
|
||||
const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
|
||||
export interface UseModalKeyboardOptions {
|
||||
restoreFocusRef?: Ref<HTMLElement | null>
|
||||
}
|
||||
|
||||
export function useModalKeyboard(
|
||||
containerRef: Ref<HTMLElement | null>,
|
||||
isOpen: Ref<boolean>,
|
||||
onClose: () => void,
|
||||
options?: UseModalKeyboardOptions
|
||||
) {
|
||||
const restoreFocusRef = options?.restoreFocusRef
|
||||
|
||||
// Save the element that had focus when modal opens (before focus moves to modal)
|
||||
watch(isOpen, (open) => {
|
||||
if (open && restoreFocusRef) {
|
||||
restoreFocusRef.value = document.activeElement as HTMLElement | null
|
||||
}
|
||||
})
|
||||
function getFocusables(): HTMLElement[] {
|
||||
const el = containerRef.value
|
||||
if (!el) return []
|
||||
return Array.from(el.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
|
||||
(e) => e.offsetParent !== null
|
||||
)
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (!isOpen.value) return
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
restoreFocusRef?.value?.focus?.()
|
||||
onClose()
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return
|
||||
}
|
||||
|
||||
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
||||
const focusables = getFocusables()
|
||||
if (focusables.length === 0) return
|
||||
|
||||
const current = document.activeElement as HTMLElement | null
|
||||
const idx = current ? focusables.indexOf(current) : -1
|
||||
|
||||
let nextIdx: number
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
|
||||
nextIdx = idx < focusables.length - 1 ? idx + 1 : 0
|
||||
} else {
|
||||
nextIdx = idx > 0 ? idx - 1 : focusables.length - 1
|
||||
}
|
||||
|
||||
focusables[nextIdx]?.focus()
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', handleKeydown, true)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', handleKeydown, true)
|
||||
})
|
||||
}
|
||||
70
neode-ui/src/composables/useNavSounds.ts
Normal file
70
neode-ui/src/composables/useNavSounds.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Epic interface sounds for controller/keyboard navigation.
|
||||
* Layered synthesis - cool, impactful, celebratory for actions.
|
||||
*/
|
||||
|
||||
let audioContext: AudioContext | null = null
|
||||
|
||||
function getContext(): AudioContext | null {
|
||||
if (audioContext) return audioContext
|
||||
try {
|
||||
audioContext = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)()
|
||||
return audioContext
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function playTone(
|
||||
ctx: AudioContext,
|
||||
freq: number,
|
||||
duration: number,
|
||||
gain: number,
|
||||
type: OscillatorType = 'sine',
|
||||
startOffset = 0
|
||||
) {
|
||||
const osc = ctx.createOscillator()
|
||||
const g = ctx.createGain()
|
||||
osc.connect(g)
|
||||
g.connect(ctx.destination)
|
||||
g.gain.setValueAtTime(0, ctx.currentTime)
|
||||
g.gain.linearRampToValueAtTime(gain, ctx.currentTime + 0.01)
|
||||
g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration)
|
||||
osc.frequency.value = freq
|
||||
osc.type = type
|
||||
osc.start(ctx.currentTime + startOffset)
|
||||
osc.stop(ctx.currentTime + startOffset + duration)
|
||||
}
|
||||
|
||||
export function playNavSound(type: 'move' | 'select' | 'action' | 'back' = 'move') {
|
||||
if (type === 'move') {
|
||||
const audio = new Audio('/assets/audio/arrows.mp3')
|
||||
audio.volume = 0.5
|
||||
audio.play().catch(() => {})
|
||||
return
|
||||
}
|
||||
if (type === 'select' || type === 'action') {
|
||||
const audio = new Audio('/assets/audio/enter.mp3')
|
||||
audio.volume = 0.5
|
||||
audio.play().catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
const ctx = getContext()
|
||||
if (!ctx) return
|
||||
|
||||
try {
|
||||
if (ctx.state === 'suspended') ctx.resume()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'back': {
|
||||
playTone(ctx, 440, 0.06, 0.08, 'sine')
|
||||
playTone(ctx, 330, 0.08, 0.05, 'sine', 0.03)
|
||||
playTone(ctx, 220, 0.1, 0.04, 'triangle', 0.05)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
30
neode-ui/src/composables/useOnboarding.ts
Normal file
30
neode-ui/src/composables/useOnboarding.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Onboarding state - prefers backend, falls back to localStorage for mock/offline.
|
||||
* Hardened: retries on 502/503, never blocks completion.
|
||||
*/
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
async function callWithRetry<T>(fn: () => Promise<T>, maxRetries = 3): Promise<T | null> {
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : ''
|
||||
const isRetryable = /502|503|timeout|fetch|network/i.test(msg)
|
||||
if (!isRetryable || i === maxRetries - 1) return null
|
||||
await new Promise((r) => setTimeout(r, 800 * (i + 1)))
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function isOnboardingComplete(): Promise<boolean> {
|
||||
const result = await callWithRetry(() => rpcClient.isOnboardingComplete(), 2)
|
||||
if (result !== null) return result
|
||||
return localStorage.getItem('neode_onboarding_complete') === '1'
|
||||
}
|
||||
|
||||
export async function completeOnboarding(): Promise<void> {
|
||||
await callWithRetry(() => rpcClient.completeOnboarding(), 3)
|
||||
localStorage.setItem('neode_onboarding_complete', '1')
|
||||
}
|
||||
47
neode-ui/src/composables/useToast.ts
Normal file
47
neode-ui/src/composables/useToast.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { ref, readonly } from 'vue'
|
||||
|
||||
export type ToastVariant = 'success' | 'error' | 'info'
|
||||
|
||||
export interface ToastItem {
|
||||
id: number
|
||||
message: string
|
||||
variant: ToastVariant
|
||||
dismissing: boolean
|
||||
}
|
||||
|
||||
const toasts = ref<ToastItem[]>([])
|
||||
let nextId = 0
|
||||
|
||||
function addToast(message: string, variant: ToastVariant = 'info', duration = 3000) {
|
||||
const id = nextId++
|
||||
toasts.value.push({ id, message, variant, dismissing: false })
|
||||
|
||||
// Auto-dismiss
|
||||
if (duration > 0) {
|
||||
setTimeout(() => dismissToast(id), duration)
|
||||
}
|
||||
|
||||
// Cap at 5 visible toasts
|
||||
if (toasts.value.length > 5) {
|
||||
toasts.value.shift()
|
||||
}
|
||||
}
|
||||
|
||||
function dismissToast(id: number) {
|
||||
const idx = toasts.value.findIndex(t => t.id === id)
|
||||
if (idx === -1) return
|
||||
toasts.value[idx]!.dismissing = true
|
||||
setTimeout(() => {
|
||||
toasts.value = toasts.value.filter(t => t.id !== id)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
return {
|
||||
toasts: readonly(toasts),
|
||||
success: (msg: string) => addToast(msg, 'success'),
|
||||
error: (msg: string) => addToast(msg, 'error'),
|
||||
info: (msg: string) => addToast(msg, 'info'),
|
||||
dismiss: dismissToast,
|
||||
}
|
||||
}
|
||||
304
neode-ui/src/data/goals.ts
Normal file
304
neode-ui/src/data/goals.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import type { GoalDefinition } from '@/types/goals'
|
||||
|
||||
export const GOALS: GoalDefinition[] = [
|
||||
{
|
||||
id: 'open-a-shop',
|
||||
title: 'Open a Shop',
|
||||
subtitle: 'Accept Bitcoin payments with your own online store',
|
||||
icon: 'shop',
|
||||
category: 'commerce',
|
||||
requiredApps: ['bitcoin-knots', 'lnd', 'btcpay-server'],
|
||||
steps: [
|
||||
{
|
||||
id: 'install-bitcoin',
|
||||
title: 'Install Bitcoin Node',
|
||||
description: 'Bitcoin Knots validates transactions and maintains the blockchain on your hardware. This is the foundation of your sovereign payment stack.',
|
||||
appId: 'bitcoin-knots',
|
||||
action: 'install',
|
||||
isAutomatic: true,
|
||||
},
|
||||
{
|
||||
id: 'install-lnd',
|
||||
title: 'Install Lightning Network',
|
||||
description: 'LND enables instant, low-fee Bitcoin payments through payment channels. Your customers can pay in seconds.',
|
||||
appId: 'lnd',
|
||||
action: 'install',
|
||||
isAutomatic: true,
|
||||
},
|
||||
{
|
||||
id: 'install-btcpay',
|
||||
title: 'Install BTCPay Server',
|
||||
description: 'BTCPay Server is your self-hosted payment processor. Create invoices, manage your store, and accept payments — all without middlemen.',
|
||||
appId: 'btcpay-server',
|
||||
action: 'install',
|
||||
isAutomatic: true,
|
||||
},
|
||||
{
|
||||
id: 'configure-store',
|
||||
title: 'Set Up Your Store',
|
||||
description: 'Create your store, set your currency, and customize your payment page. BTCPay will open so you can configure everything.',
|
||||
action: 'configure',
|
||||
isAutomatic: false,
|
||||
},
|
||||
],
|
||||
estimatedTime: '~45 min + sync time',
|
||||
difficulty: 'beginner',
|
||||
},
|
||||
{
|
||||
id: 'accept-payments',
|
||||
title: 'Accept Payments',
|
||||
subtitle: 'Receive Bitcoin and Lightning payments directly',
|
||||
icon: 'payments',
|
||||
category: 'payments',
|
||||
requiredApps: ['bitcoin-knots', 'lnd'],
|
||||
steps: [
|
||||
{
|
||||
id: 'install-bitcoin',
|
||||
title: 'Install Bitcoin Node',
|
||||
description: 'Your own Bitcoin node verifies every transaction independently. No trust required.',
|
||||
appId: 'bitcoin-knots',
|
||||
action: 'install',
|
||||
isAutomatic: true,
|
||||
},
|
||||
{
|
||||
id: 'install-lnd',
|
||||
title: 'Install Lightning Network',
|
||||
description: 'Lightning enables instant payments with tiny fees. Perfect for everyday transactions.',
|
||||
appId: 'lnd',
|
||||
action: 'install',
|
||||
isAutomatic: true,
|
||||
},
|
||||
{
|
||||
id: 'open-channel',
|
||||
title: 'Open a Lightning Channel',
|
||||
description: 'Open your first payment channel to start sending and receiving Lightning payments. LND will guide you through it.',
|
||||
action: 'configure',
|
||||
isAutomatic: false,
|
||||
},
|
||||
],
|
||||
estimatedTime: '~30 min + sync time',
|
||||
difficulty: 'beginner',
|
||||
},
|
||||
{
|
||||
id: 'store-photos',
|
||||
title: 'Store My Photos',
|
||||
subtitle: 'Private photo backup and gallery on your own hardware',
|
||||
icon: 'photos',
|
||||
category: 'storage',
|
||||
requiredApps: ['immich'],
|
||||
steps: [
|
||||
{
|
||||
id: 'install-immich',
|
||||
title: 'Install Immich',
|
||||
description: 'Immich is a self-hosted photo and video management solution. It looks and feels like Google Photos, but your data stays on your server.',
|
||||
appId: 'immich',
|
||||
action: 'install',
|
||||
isAutomatic: true,
|
||||
},
|
||||
{
|
||||
id: 'configure-immich',
|
||||
title: 'Create Your Account',
|
||||
description: 'Set up your Immich account and configure your photo library. Quick and simple.',
|
||||
action: 'configure',
|
||||
isAutomatic: false,
|
||||
},
|
||||
{
|
||||
id: 'mobile-sync',
|
||||
title: 'Connect Your Phone',
|
||||
description: 'Download the Immich app on your phone and scan the QR code to start automatic photo backup.',
|
||||
action: 'info',
|
||||
isAutomatic: false,
|
||||
},
|
||||
],
|
||||
estimatedTime: '~15 min',
|
||||
difficulty: 'beginner',
|
||||
},
|
||||
{
|
||||
id: 'store-files',
|
||||
title: 'Store My Files',
|
||||
subtitle: 'Personal cloud storage and file sync',
|
||||
icon: 'files',
|
||||
category: 'storage',
|
||||
requiredApps: ['nextcloud'],
|
||||
steps: [
|
||||
{
|
||||
id: 'install-nextcloud',
|
||||
title: 'Install Cloud Storage',
|
||||
description: 'Nextcloud gives you a full cloud storage platform — files, calendars, contacts, and more. Like Dropbox, but sovereign.',
|
||||
appId: 'nextcloud',
|
||||
action: 'install',
|
||||
isAutomatic: true,
|
||||
},
|
||||
{
|
||||
id: 'configure-nextcloud',
|
||||
title: 'Set Up Your Cloud',
|
||||
description: 'Create your admin account and configure storage. Nextcloud will open for you to complete setup.',
|
||||
action: 'configure',
|
||||
isAutomatic: false,
|
||||
},
|
||||
{
|
||||
id: 'sync-setup',
|
||||
title: 'Sync Your Devices',
|
||||
description: 'Install the Nextcloud app on your phone and computer to keep your files in sync across all devices.',
|
||||
action: 'info',
|
||||
isAutomatic: false,
|
||||
},
|
||||
],
|
||||
estimatedTime: '~20 min',
|
||||
difficulty: 'beginner',
|
||||
},
|
||||
{
|
||||
id: 'run-lightning-node',
|
||||
title: 'Run a Lightning Node',
|
||||
subtitle: 'Route payments and earn sats on the Lightning Network',
|
||||
icon: 'lightning',
|
||||
category: 'network',
|
||||
requiredApps: ['bitcoin-knots', 'lnd'],
|
||||
steps: [
|
||||
{
|
||||
id: 'install-bitcoin',
|
||||
title: 'Install Bitcoin Node',
|
||||
description: 'The Bitcoin blockchain is the settlement layer. Your node needs to sync the full chain before Lightning can start.',
|
||||
appId: 'bitcoin-knots',
|
||||
action: 'install',
|
||||
isAutomatic: true,
|
||||
},
|
||||
{
|
||||
id: 'install-lnd',
|
||||
title: 'Install LND',
|
||||
description: 'LND is a full Lightning Network node. You can route payments for others and earn routing fees.',
|
||||
appId: 'lnd',
|
||||
action: 'install',
|
||||
isAutomatic: true,
|
||||
},
|
||||
{
|
||||
id: 'open-channels',
|
||||
title: 'Open Payment Channels',
|
||||
description: 'Open channels with well-connected nodes to start routing payments. More channels means more routing opportunities.',
|
||||
action: 'configure',
|
||||
isAutomatic: false,
|
||||
},
|
||||
{
|
||||
id: 'verify-routing',
|
||||
title: 'Verify Node is Routing',
|
||||
description: 'Check that your node is visible on the network and ready to route payments.',
|
||||
action: 'verify',
|
||||
isAutomatic: true,
|
||||
},
|
||||
],
|
||||
estimatedTime: '~40 min + sync time',
|
||||
difficulty: 'intermediate',
|
||||
},
|
||||
{
|
||||
id: 'setup-fedimint',
|
||||
title: 'Create a Community',
|
||||
subtitle: 'Start a Fedimint federation for private, scalable Bitcoin',
|
||||
icon: 'community',
|
||||
category: 'community',
|
||||
requiredApps: ['bitcoin-knots', 'fedimint'],
|
||||
steps: [
|
||||
{
|
||||
id: 'install-bitcoin',
|
||||
title: 'Install Bitcoin Node',
|
||||
description: 'Bitcoin Knots provides the base layer that Fedimint connects to for on-chain transactions and consensus.',
|
||||
appId: 'bitcoin-knots',
|
||||
action: 'install',
|
||||
isAutomatic: true,
|
||||
},
|
||||
{
|
||||
id: 'install-fedimint',
|
||||
title: 'Install Fedimint',
|
||||
description: 'Fedimint is a federated Bitcoin mint. Guardians collectively manage funds using threshold signatures — no single point of failure.',
|
||||
appId: 'fedimint',
|
||||
action: 'install',
|
||||
isAutomatic: true,
|
||||
},
|
||||
{
|
||||
id: 'configure-guardian',
|
||||
title: 'Set Up Guardian UI',
|
||||
description: 'Open the Guardian UI (port 8175) to configure your federation name, set the guardian threshold, and initialize the mint.',
|
||||
action: 'configure',
|
||||
isAutomatic: false,
|
||||
},
|
||||
{
|
||||
id: 'share-invite',
|
||||
title: 'Share Invite Code',
|
||||
description: 'Generate and share your federation invite code with community members so they can join and start using ecash.',
|
||||
action: 'info',
|
||||
isAutomatic: false,
|
||||
},
|
||||
],
|
||||
estimatedTime: '~30 min + sync time',
|
||||
difficulty: 'intermediate',
|
||||
},
|
||||
{
|
||||
id: 'create-identity',
|
||||
title: 'Create My Identity',
|
||||
subtitle: 'Sovereign digital identity with DID and Nostr',
|
||||
icon: 'identity',
|
||||
category: 'identity',
|
||||
requiredApps: [],
|
||||
steps: [
|
||||
{
|
||||
id: 'generate-did',
|
||||
title: 'Generate Your Identity',
|
||||
description: 'Your server creates a cryptographic identity (DID) that you own and control. No company can revoke it.',
|
||||
action: 'verify',
|
||||
isAutomatic: true,
|
||||
},
|
||||
{
|
||||
id: 'setup-nostr',
|
||||
title: 'Set Up Nostr Profile',
|
||||
description: 'Publish your identity to the Nostr network. This lets you sign into Nostr-compatible apps directly from your server.',
|
||||
action: 'configure',
|
||||
isAutomatic: false,
|
||||
},
|
||||
{
|
||||
id: 'export-identity',
|
||||
title: 'Export Your Identity',
|
||||
description: 'Save your identity credentials for backup. This is your portable sovereign identity — take it anywhere.',
|
||||
action: 'info',
|
||||
isAutomatic: false,
|
||||
},
|
||||
],
|
||||
estimatedTime: '~5 min',
|
||||
difficulty: 'beginner',
|
||||
},
|
||||
{
|
||||
id: 'back-up-everything',
|
||||
title: 'Back Up Everything',
|
||||
subtitle: 'Encrypted backup of your entire node',
|
||||
icon: 'backup',
|
||||
category: 'backup',
|
||||
requiredApps: [],
|
||||
steps: [
|
||||
{
|
||||
id: 'create-passphrase',
|
||||
title: 'Create a Passphrase',
|
||||
description: 'Choose a strong passphrase to encrypt your backup. Without this passphrase, nobody can access your data — not even us.',
|
||||
action: 'configure',
|
||||
isAutomatic: false,
|
||||
},
|
||||
{
|
||||
id: 'create-backup',
|
||||
title: 'Create Encrypted Backup',
|
||||
description: 'Your server will create a complete encrypted backup of all your data, keys, and configuration.',
|
||||
action: 'verify',
|
||||
isAutomatic: true,
|
||||
},
|
||||
{
|
||||
id: 'save-backup',
|
||||
title: 'Save Your Backup',
|
||||
description: 'Download your encrypted backup file and store it somewhere safe. Consider keeping a copy on a USB drive and in the cloud.',
|
||||
action: 'info',
|
||||
isAutomatic: false,
|
||||
},
|
||||
],
|
||||
estimatedTime: '~10 min',
|
||||
difficulty: 'beginner',
|
||||
},
|
||||
]
|
||||
|
||||
export function getGoalById(id: string): GoalDefinition | undefined {
|
||||
return GOALS.find((g) => g.id === id)
|
||||
}
|
||||
149
neode-ui/src/data/helpTree.ts
Normal file
149
neode-ui/src/data/helpTree.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
export interface HelpSection {
|
||||
id: string
|
||||
label: string
|
||||
items: HelpItem[]
|
||||
}
|
||||
|
||||
export interface HelpItem {
|
||||
id: string
|
||||
label: string
|
||||
path?: string
|
||||
content?: string
|
||||
relatedPath?: string
|
||||
}
|
||||
|
||||
export interface SearchableItem {
|
||||
id: string
|
||||
label: string
|
||||
path?: string
|
||||
type: 'navigate' | 'learn' | 'action' | 'goal'
|
||||
section: string
|
||||
content?: string
|
||||
relatedPath?: string
|
||||
}
|
||||
|
||||
export const helpTree: HelpSection[] = [
|
||||
{
|
||||
id: 'navigate',
|
||||
label: 'Navigate',
|
||||
items: [
|
||||
{ id: 'home', label: 'Home', path: '/dashboard' },
|
||||
{ id: 'apps', label: 'My Apps', path: '/dashboard/apps' },
|
||||
{ id: 'marketplace', label: 'App Store', path: '/dashboard/marketplace' },
|
||||
{ id: 'cloud', label: 'Cloud', path: '/dashboard/cloud' },
|
||||
{ id: 'server', label: 'Network', path: '/dashboard/server' },
|
||||
{ id: 'web5', label: 'Web5', path: '/dashboard/web5' },
|
||||
{ id: 'settings', label: 'Settings', path: '/dashboard/settings' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'learn',
|
||||
label: 'Learn',
|
||||
items: [
|
||||
{
|
||||
id: 'getting-started',
|
||||
label: 'Getting Started',
|
||||
content: 'Welcome to Archipelago! To get started: 1) Flash the Archipelago ISO to a USB drive using Balena Etcher or dd. 2) Boot your hardware from the USB. 3) The installer partitions your disk, installs the OS, and reboots automatically. 4) On first boot, open a browser and navigate to your node\'s IP address. 5) Complete the onboarding wizard — set a password, create your first identity (DID), and choose your setup path. Your node is now ready to install apps, connect to Bitcoin, and join the sovereign web.',
|
||||
relatedPath: '/dashboard',
|
||||
},
|
||||
{
|
||||
id: 'bitcoin-basics',
|
||||
label: 'Bitcoin Basics',
|
||||
content: 'Bitcoin is a decentralized digital currency. Your node validates transactions and maintains the blockchain locally. Install Bitcoin Knots from the App Store to run a full node. Initial sync takes 2-3 days depending on hardware and internet speed. Once synced, your node independently verifies every transaction without trusting any third party.',
|
||||
relatedPath: '/dashboard/server',
|
||||
},
|
||||
{
|
||||
id: 'lightning-network',
|
||||
label: 'Lightning Network',
|
||||
content: 'Lightning enables instant, low-fee payments. Install LND from the App Store (requires Bitcoin Knots). After installation, open channels with other nodes to send and receive payments. Use the Lightning Channels view to manage channels, check inbound/outbound liquidity, and monitor your routing fees. Channels require an on-chain transaction to open and close.',
|
||||
relatedPath: '/dashboard/apps',
|
||||
},
|
||||
{
|
||||
id: 'app-store-guide',
|
||||
label: 'Installing & Managing Apps',
|
||||
content: 'Open the App Store (marketplace icon) to browse available apps. Click Install to download and start an app. Some apps have dependencies — Electrs requires Bitcoin, BTCPay requires LND, Mempool requires both Bitcoin and Electrs. The system handles these automatically. After installation, apps appear in My Apps. Click an app to open it in an overlay or new tab. Use the app detail page to start, stop, restart, or uninstall apps.',
|
||||
relatedPath: '/dashboard/marketplace',
|
||||
},
|
||||
{
|
||||
id: 'identity-guide',
|
||||
label: 'Your Digital Identity (DIDs)',
|
||||
content: 'Archipelago creates a sovereign digital identity (DID) during onboarding. DIDs are cryptographic keypairs that prove your identity without any company in the middle. You can create multiple identities for different purposes — Personal, Business, or Anonymous. Each identity can sign messages, issue verifiable credentials, and authenticate with services like Indeehub. Manage identities in the Web5 view.',
|
||||
relatedPath: '/dashboard/web5',
|
||||
},
|
||||
{
|
||||
id: 'networking-guide',
|
||||
label: 'Connecting with Peers',
|
||||
content: 'Archipelago nodes can discover and connect with each other over Tor. In Web5, set your node visibility to "Discoverable" to let other nodes find you via Nostr. Accept connection requests from peers you trust. Once connected, you can message peers, share content, and exchange ecash payments — all over encrypted Tor connections. Your .onion address is shown in Settings.',
|
||||
relatedPath: '/dashboard/web5',
|
||||
},
|
||||
{
|
||||
id: 'content-sharing',
|
||||
label: 'Sharing Content',
|
||||
content: 'Share files and media with connected peers through the Content section in Web5. Add content from your Cloud storage, set it as free or paid (ecash-gated), and connected peers can browse and access your catalog. For paid content, peers pay with ecash micropayments — the sats appear in your wallet instantly.',
|
||||
relatedPath: '/dashboard/web5',
|
||||
},
|
||||
{
|
||||
id: 'self-hosting',
|
||||
label: 'Self-Hosting',
|
||||
content: 'Archipelago runs your services locally. Your data stays on your hardware, giving you full control and privacy. No cloud subscriptions, no data harvesting, no service shutdowns. You own your node, your data, and your identity. Back up your node regularly using the backup feature in Settings.',
|
||||
relatedPath: '/dashboard',
|
||||
},
|
||||
{
|
||||
id: 'troubleshooting',
|
||||
label: 'Troubleshooting FAQ',
|
||||
content: 'Common issues: 1) App won\'t start — check disk space in Settings > Server. 2) Bitcoin not syncing — ensure port 8333 is reachable; check network diagnostics. 3) Can\'t connect to peers — verify Tor is running (Settings > Network). 4) UI is slow — some views load data from multiple sources; check server resources. 5) Lost password — use the backup recovery key created during onboarding. 6) Container errors — try stopping and restarting the app, or uninstall and reinstall.',
|
||||
relatedPath: '/dashboard/settings',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
label: 'Actions',
|
||||
items: [
|
||||
{ id: 'open-cli', label: 'Open CLI', path: '__cli__' },
|
||||
{ id: 'install-app', label: 'Install an App', path: '/dashboard/marketplace' },
|
||||
{ id: 'manage-apps', label: 'Manage My Apps', path: '/dashboard/apps' },
|
||||
{ id: 'network-settings', label: 'Network Settings', path: '/dashboard/server' },
|
||||
{ id: 'backup', label: 'Backup & Recovery', path: '/dashboard/settings' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'goals',
|
||||
label: 'Quick Start Goals',
|
||||
items: [
|
||||
{ id: 'goal-shop', label: 'Open a Shop', path: '/dashboard/goals/open-a-shop' },
|
||||
{ id: 'goal-payments', label: 'Accept Payments', path: '/dashboard/goals/accept-payments' },
|
||||
{ id: 'goal-photos', label: 'Store My Photos', path: '/dashboard/goals/store-photos' },
|
||||
{ id: 'goal-files', label: 'Store My Files', path: '/dashboard/goals/store-files' },
|
||||
{ id: 'goal-lightning', label: 'Run a Lightning Node', path: '/dashboard/goals/run-lightning-node' },
|
||||
{ id: 'goal-identity', label: 'Create My Identity', path: '/dashboard/goals/create-identity' },
|
||||
{ id: 'goal-backup', label: 'Back Up Everything', path: '/dashboard/goals/back-up-everything' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function flattenForSearch(): SearchableItem[] {
|
||||
const result: SearchableItem[] = []
|
||||
for (const section of helpTree) {
|
||||
const type =
|
||||
section.id === 'navigate'
|
||||
? 'navigate'
|
||||
: section.id === 'learn'
|
||||
? 'learn'
|
||||
: section.id === 'goals'
|
||||
? 'goal'
|
||||
: 'action'
|
||||
for (const item of section.items) {
|
||||
result.push({
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
path: item.path,
|
||||
type,
|
||||
section: section.label,
|
||||
content: item.content,
|
||||
relatedPath: item.relatedPath,
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
48
neode-ui/src/i18n.ts
Normal file
48
neode-ui/src/i18n.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import en from './locales/en.json'
|
||||
|
||||
export type MessageSchema = typeof en
|
||||
|
||||
export const SUPPORTED_LOCALES = [
|
||||
{ code: 'en', name: 'English', flag: '🇬🇧' },
|
||||
{ code: 'es', name: 'Español', flag: '🇪🇸' },
|
||||
] as const
|
||||
|
||||
export type SupportedLocale = typeof SUPPORTED_LOCALES[number]['code']
|
||||
|
||||
const savedLocale = (typeof localStorage !== 'undefined'
|
||||
? localStorage.getItem('neode_locale')
|
||||
: null) as SupportedLocale | null
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: savedLocale || 'en',
|
||||
fallbackLocale: 'en',
|
||||
messages: {
|
||||
en,
|
||||
},
|
||||
})
|
||||
|
||||
/** Lazy-load a locale's messages and switch to it. */
|
||||
export async function setLocale(locale: SupportedLocale) {
|
||||
if (locale === 'en') {
|
||||
i18n.global.locale = 'en' as never
|
||||
localStorage.setItem('neode_locale', 'en')
|
||||
return
|
||||
}
|
||||
|
||||
if (!(i18n.global.availableLocales as string[]).includes(locale)) {
|
||||
const messages = await import(`./locales/${locale}.json`)
|
||||
;(i18n.global as ReturnType<typeof createI18n>['global']).setLocaleMessage(locale, messages.default)
|
||||
}
|
||||
|
||||
i18n.global.locale = locale as never
|
||||
localStorage.setItem('neode_locale', locale)
|
||||
}
|
||||
|
||||
// Load saved locale on startup
|
||||
if (savedLocale && savedLocale !== 'en') {
|
||||
setLocale(savedLocale)
|
||||
}
|
||||
|
||||
export default i18n
|
||||
707
neode-ui/src/locales/en.json
Normal file
707
neode-ui/src/locales/en.json
Normal file
@@ -0,0 +1,707 @@
|
||||
{
|
||||
"common": {
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"close": "Close",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"copiedBang": "Copied!",
|
||||
"loading": "Loading...",
|
||||
"retry": "Retry",
|
||||
"refresh": "Refresh",
|
||||
"install": "Install",
|
||||
"installing": "Installing...",
|
||||
"uninstall": "Uninstall",
|
||||
"uninstalling": "Uninstalling...",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"restart": "Restart",
|
||||
"launch": "Launch",
|
||||
"starting": "Starting...",
|
||||
"stopping": "Stopping...",
|
||||
"send": "Send",
|
||||
"sending": "Sending...",
|
||||
"back": "Back",
|
||||
"done": "Done",
|
||||
"manage": "Manage",
|
||||
"connect": "Connect",
|
||||
"connecting": "Connecting...",
|
||||
"disconnect": "Disconnect",
|
||||
"running": "running",
|
||||
"stopped": "stopped",
|
||||
"exited": "exited",
|
||||
"healthy": "Healthy",
|
||||
"elevated": "Elevated",
|
||||
"critical": "Critical",
|
||||
"connected": "Connected",
|
||||
"disconnected": "Disconnected",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"synced": "Synced",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"dismiss": "Dismiss",
|
||||
"apply": "Apply",
|
||||
"configure": "Configure",
|
||||
"export": "Export",
|
||||
"delete": "Delete",
|
||||
"remove": "Remove",
|
||||
"error": "Error",
|
||||
"version": "Version",
|
||||
"status": "Status",
|
||||
"category": "Category",
|
||||
"developer": "Developer",
|
||||
"license": "License",
|
||||
"never": "Never",
|
||||
"notAvailable": "Not Available",
|
||||
"goBack": "Go back",
|
||||
"skipToContent": "Skip to main content",
|
||||
"continue": "Continue",
|
||||
"verify": "Verify",
|
||||
"create": "Create",
|
||||
"restore": "Restore",
|
||||
"disabling": "Disabling...",
|
||||
"creating": "Creating...",
|
||||
"restoring": "Restoring...",
|
||||
"manageUpdates": "Manage Updates",
|
||||
"enableAll": "Enable All",
|
||||
"networkDiagnostics": "Network Diagnostics",
|
||||
"network": "Network",
|
||||
"saveConfiguration": "Save Configuration",
|
||||
"sendTest": "Send Test"
|
||||
},
|
||||
"login": {
|
||||
"title": "Welcome to Archipelago",
|
||||
"setupTitle": "Set Up Your Node",
|
||||
"twoFactorTitle": "Two-Factor Authentication",
|
||||
"password": "Password",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"enterPasswordPlaceholder": "Enter your password",
|
||||
"enterPasswordSetup": "Enter a password (min 8 characters)",
|
||||
"confirmPasswordPlaceholder": "Confirm your password",
|
||||
"setupButton": "Set Up Node",
|
||||
"settingUp": "Setting up...",
|
||||
"loginButton": "Login",
|
||||
"loggingIn": "Logging in...",
|
||||
"verifyButton": "Verify",
|
||||
"verifying": "Verifying...",
|
||||
"useAuthCode": "Use authenticator code",
|
||||
"useBackupCode": "Use a backup code instead",
|
||||
"totpInstruction": "Enter the 6-digit code from your authenticator app",
|
||||
"totpPlaceholder": "000000",
|
||||
"backupCodePlaceholder": "XXXX-XXXX",
|
||||
"serverStarting": "Server starting up...",
|
||||
"replayIntro": "Replay Intro",
|
||||
"onboarding": "Onboarding",
|
||||
"resetting": "Resetting...",
|
||||
"recoveryNote": "Password recovery requires SSH access to the server.",
|
||||
"errorMinLength": "Password must be at least 8 characters",
|
||||
"errorMismatch": "Passwords do not match",
|
||||
"errorServerStarting": "Server is starting up. Please try again in a moment.",
|
||||
"errorSetupFailed": "Setup failed. Please try again.",
|
||||
"errorLoginFailed": "Login failed. Please check your password.",
|
||||
"errorInvalidCode": "Invalid code. Please try again.",
|
||||
"totpLabel": "Two-factor authentication code"
|
||||
},
|
||||
"home": {
|
||||
"title": "Welcome Noderunner",
|
||||
"subtitle": "Here's an overview of your sovereign life",
|
||||
"dashboardTab": "Dashboard",
|
||||
"setupTab": "Setup",
|
||||
"myApps": "My Apps",
|
||||
"myAppsDesc": "Manage your installed applications",
|
||||
"cloud": "Cloud",
|
||||
"cloudDesc": "Cloud services and storage",
|
||||
"network": "Network",
|
||||
"networkDesc": "Network infrastructure and Web3 services",
|
||||
"web5": "Web5",
|
||||
"web5Desc": "Decentralized identity and data protocols",
|
||||
"system": "System",
|
||||
"quickStartGoals": "Quick Start Goals",
|
||||
"quickStartDesc": "Not sure where to start? Try a guided setup.",
|
||||
"installed": "Installed",
|
||||
"runningLabel": "Running",
|
||||
"storageUsed": "Storage Used",
|
||||
"folders": "Folders",
|
||||
"servicesStatus": "Services Status",
|
||||
"connectivity": "Connectivity",
|
||||
"runningApps": "Running Apps",
|
||||
"didStatus": "DID Status",
|
||||
"dwnSync": "DWN Sync",
|
||||
"credentials": "Credentials",
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM",
|
||||
"disk": "Disk",
|
||||
"browseStore": "Browse Store",
|
||||
"manageApps": "Manage Apps",
|
||||
"viewFolders": "View Folders",
|
||||
"uploadFiles": "Upload Files",
|
||||
"manageNetwork": "Manage Network",
|
||||
"manageWeb5": "Manage Web5",
|
||||
"openAI": "Open AI Assistant",
|
||||
"noApps": "No Apps",
|
||||
"allRunning": "All Running",
|
||||
"systemMonitoring": "System monitoring",
|
||||
"updateAvailable": "Update Available: v{version}",
|
||||
"updateNow": "Update Now",
|
||||
"goToApps": "Go to Apps",
|
||||
"goToCloud": "Go to Cloud",
|
||||
"goToNetwork": "Go to Network",
|
||||
"goToWeb5": "Go to Web5",
|
||||
"goToSettings": "Go to Settings"
|
||||
},
|
||||
"apps": {
|
||||
"title": "My Apps",
|
||||
"subtitle": "Manage your installed applications",
|
||||
"searchPlaceholder": "Search installed apps...",
|
||||
"noAppsTitle": "No Apps Installed",
|
||||
"noAppsMessage": "Get started by browsing the app store",
|
||||
"browseAppStore": "Browse App Store",
|
||||
"noResults": "No apps matching \"{query}\"",
|
||||
"uninstallTitle": "Uninstall App?",
|
||||
"uninstallConfirm": "Are you sure you want to uninstall {name}? This will remove the app and stop its container.",
|
||||
"dismissError": "Dismiss error",
|
||||
"searchLabel": "Search installed apps"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"subtitle": "Configure your Archipelago experience",
|
||||
"account": "Account",
|
||||
"interfaceMode": "Interface Mode",
|
||||
"claudeAuth": "Claude Authentication",
|
||||
"aiDataAccess": "AI Data Access",
|
||||
"serverName": "Server Name",
|
||||
"sessionStatus": "Session Status",
|
||||
"yourDid": "Your DID",
|
||||
"onionAddress": "Node .onion Address",
|
||||
"loggedIn": "Currently logged in",
|
||||
"didHelper": "Decentralized identifier for passwordless auth",
|
||||
"onionHelper": "Onion address for node interface and peer discovery over Tor",
|
||||
"changePassword": "Change Password",
|
||||
"enable2fa": "Enable 2FA",
|
||||
"disable2fa": "Disable 2FA",
|
||||
"logout": "Logout",
|
||||
"loggingOut": "Logging out...",
|
||||
"twoFactorAuth": "Two-Factor Authentication",
|
||||
"twoFaProtect": "Protect your account with an authenticator app",
|
||||
"changePasswordTitle": "Change Password",
|
||||
"changePasswordDesc": "Updates both web login and SSH access. Use a strong password (12+ chars, upper, lower, digit, special).",
|
||||
"currentPassword": "Current Password",
|
||||
"newPassword": "New Password",
|
||||
"confirmNewPassword": "Confirm New Password",
|
||||
"passwordPlaceholder": "12+ chars, upper, lower, digit, special",
|
||||
"updateSshCheckbox": "Also update SSH password (recommended)",
|
||||
"updatePassword": "Update Password",
|
||||
"updatingPassword": "Updating...",
|
||||
"setup2faTitle": "Two-Factor Authentication",
|
||||
"setup2faPasswordPrompt": "Enter your password to begin setup.",
|
||||
"scanQrCode": "Scan QR Code",
|
||||
"scanQrInstruction": "Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.), then enter the 6-digit code.",
|
||||
"manualEntryKey": "Manual entry key:",
|
||||
"verifyAndEnable": "Verify & Enable",
|
||||
"saveBackupCodes": "Save Your Backup Codes",
|
||||
"backupCodesInstruction": "Store these codes safely. Each can be used once if you lose access to your authenticator app.",
|
||||
"copyAllCodes": "Copy All Codes",
|
||||
"disable2faTitle": "Disable Two-Factor Authentication",
|
||||
"disable2faDesc": "Enter your password and a current TOTP code to disable 2FA.",
|
||||
"authenticatorCode": "Authenticator Code",
|
||||
"webhooks": "Webhooks",
|
||||
"webhooksDesc": "Get notified when important events happen on your node",
|
||||
"webhookUrl": "Webhook URL",
|
||||
"webhookUrlPlaceholder": "https://example.com/webhook",
|
||||
"webhookSecret": "Secret (for HMAC signing)",
|
||||
"webhookSecretPlaceholder": "Optional shared secret",
|
||||
"webhookEvents": "Events",
|
||||
"containerCrash": "Container Crash",
|
||||
"updateAvailableEvent": "Update Available",
|
||||
"diskWarning": "Disk Warning",
|
||||
"backupComplete": "Backup Complete",
|
||||
"saveWebhook": "Save",
|
||||
"savingWebhook": "Saving...",
|
||||
"testWebhook": "Test",
|
||||
"testingWebhook": "Testing...",
|
||||
"webhookSaved": "Webhook configuration saved",
|
||||
"webhookTestSent": "Test webhook sent successfully",
|
||||
"systemUpdates": "System Updates",
|
||||
"backup": "Backup & Restore",
|
||||
"backupDesc": "Back up your node data to external storage",
|
||||
"createBackup": "Create Backup",
|
||||
"creatingBackup": "Creating...",
|
||||
"restoreBackup": "Restore Backup",
|
||||
"deleteBackup": "Delete backup",
|
||||
"backupCreated": "Backup created successfully",
|
||||
"sendMessage": "Send Message",
|
||||
"sendMessageTitle": "Send Broadcast Message",
|
||||
"messagePlaceholder": "Enter your message...",
|
||||
"messageSent": "Message sent",
|
||||
"claudeConnected": "Connected to Claude",
|
||||
"claudeDisconnected": "Not connected",
|
||||
"claudeApiKey": "API Key",
|
||||
"claudeApiKeyPlaceholder": "Enter your Anthropic API key",
|
||||
"claudeSave": "Save Key",
|
||||
"advancedMode": "Advanced Mode",
|
||||
"beginnerMode": "Beginner Mode",
|
||||
"advancedModeDesc": "Show all system controls and developer tools",
|
||||
"beginnerModeDesc": "Simplified interface with guided experience",
|
||||
"networkSettings": "Network Settings",
|
||||
"torEnabled": "Tor Enabled",
|
||||
"torAddress": "Tor Address",
|
||||
"interfaceModeDesc": "Choose how you want to interact with your node.",
|
||||
"claudeAuthDesc": "Connect your Claude Max account to enable AI chat features.",
|
||||
"connectionStatus": "Connection Status",
|
||||
"notConnected": "Not connected",
|
||||
"reAuthenticate": "Re-authenticate",
|
||||
"loginWithClaude": "Login with Claude",
|
||||
"aiDataAccessDesc": "Control what data the AI assistant can see. All categories are off by default.",
|
||||
"enableAllDesc": "Grant access to all data categories at once",
|
||||
"systemUpdatesDesc": "Check for and install software updates",
|
||||
"webhookNotifications": "Webhook Notifications",
|
||||
"webhookNotificationsDesc": "Get push notifications for critical events via webhook",
|
||||
"enableWebhooks": "Enable webhooks",
|
||||
"disableWebhooks": "Disable webhooks",
|
||||
"webhookUrlLabel": "Webhook URL",
|
||||
"webhookSecretLabel": "Secret (optional, for HMAC-SHA256 signing)",
|
||||
"eventsToNotify": "Events to notify",
|
||||
"containerCrashDesc": "A running container stops unexpectedly",
|
||||
"updateAvailableDesc": "A new system or app update is ready",
|
||||
"diskWarningDesc": "Disk usage exceeds warning threshold",
|
||||
"backupCompleteDesc": "A scheduled or manual backup finishes",
|
||||
"backupRestoreDesc": "Encrypted backups of your identity, settings, and data",
|
||||
"loadingBackups": "Loading backups...",
|
||||
"noBackups": "No backups yet. Create one to protect your node data.",
|
||||
"systemBackup": "System Backup",
|
||||
"createEncryptedBackup": "Create Encrypted Backup",
|
||||
"encryptionPassphrase": "Encryption Passphrase",
|
||||
"enterPassphrase": "Enter a strong passphrase",
|
||||
"descriptionOptional": "Description (optional)",
|
||||
"descriptionPlaceholder": "e.g. Before update",
|
||||
"restoreBackupTitle": "Restore Backup",
|
||||
"restoreWarning": "This will overwrite current node data. Make sure you have the correct passphrase.",
|
||||
"enterBackupPassphrase": "Enter backup passphrase",
|
||||
"networkDesc": "Network connectivity, UPnP, and diagnostics",
|
||||
"webhookSecretPlaceholderFull": "Shared secret for payload signing",
|
||||
"backupCreatedSuccess": "Backup created successfully",
|
||||
"backupCreateFailed": "Failed to create backup",
|
||||
"backupVerifiedOk": "Backup verified — integrity OK",
|
||||
"backupVerifyFailed": "Verification failed: {error}",
|
||||
"backupVerifyRequestFailed": "Verification request failed",
|
||||
"backupRestored": "Backup restored. Restart may be needed.",
|
||||
"backupRestoreFailed": "Restore failed — check passphrase",
|
||||
"backupDeleted": "Backup deleted",
|
||||
"backupDeleteFailed": "Failed to delete backup",
|
||||
"noUsbDrives": "No mounted USB drives found. Insert and mount a USB drive first.",
|
||||
"backupCopiedToUsb": "Backup copied to {path}",
|
||||
"backupUsbFailed": "Failed to copy backup to USB",
|
||||
"deleteBackupConfirm": "Delete this backup permanently?",
|
||||
"verifyPassphrasePrompt": "Enter backup passphrase to verify:",
|
||||
"webhookSaveFailed": "Failed to save webhook configuration",
|
||||
"webhookTestFailed": "Test failed: webhook not sent",
|
||||
"webhookSendFailed": "Failed to send test webhook",
|
||||
"passwordAllFieldsRequired": "All fields are required",
|
||||
"passwordMismatch": "New passwords do not match",
|
||||
"passwordUpdatedSuccess": "Password updated successfully. Use the new password for login and SSH.",
|
||||
"passwordChangeFailed": "Failed to change password",
|
||||
"passwordMinLength": "Password must be at least 12 characters",
|
||||
"passwordNeedUppercase": "Password must contain at least one uppercase letter",
|
||||
"passwordNeedLowercase": "Password must contain at least one lowercase letter",
|
||||
"passwordNeedDigit": "Password must contain at least one digit",
|
||||
"passwordNeedSpecial": "Password must contain at least one special character (!@#$%^&* etc.)",
|
||||
"setupFailed": "Setup failed",
|
||||
"verificationFailed": "Verification failed",
|
||||
"disableFailed": "Failed to disable 2FA",
|
||||
"copyToUsb": "Copy to USB",
|
||||
"diskSpaceWarning": "Disk Space Warning",
|
||||
"modeEasy": "Easy",
|
||||
"modeEasyDesc": "Goal-based interface. Choose what you want to do, and the system handles the rest.",
|
||||
"modePro": "Pro",
|
||||
"modeProDesc": "Full control over all services. Configure everything manually with all technical details.",
|
||||
"modeChat": "AIUI",
|
||||
"modeChatDesc": "Conversational AI interface. Manage your node through natural language. Coming soon."
|
||||
},
|
||||
"marketplace": {
|
||||
"title": "App Store",
|
||||
"subtitle": "Discover and install apps for your new sovereign life",
|
||||
"curatedTab": "Curated",
|
||||
"communityTab": "Community",
|
||||
"nostrCommunityTab": "Nostr Community",
|
||||
"filterByCategory": "Filter by Category",
|
||||
"searchPlaceholder": "Search apps...",
|
||||
"downloading": "Downloading...",
|
||||
"alreadyInstalled": "Already Installed",
|
||||
"queryingRelays": "Querying Nostr relays for apps...",
|
||||
"noCommunityApps": "No community apps discovered yet.",
|
||||
"noResults": "No apps found in {category} matching \"{query}\"",
|
||||
"noResultsCategory": "No apps found in {category}",
|
||||
"noResultsSearch": "No apps matching \"{query}\"",
|
||||
"all": "All",
|
||||
"community": "Community",
|
||||
"commerce": "Commerce",
|
||||
"money": "Money",
|
||||
"data": "Data",
|
||||
"homeCategory": "Home",
|
||||
"auto": "Auto",
|
||||
"networking": "Networking",
|
||||
"other": "Other",
|
||||
"searchApps": "Search apps",
|
||||
"percentComplete": "{percent}% complete"
|
||||
},
|
||||
"dashboard": {
|
||||
"mainNav": "Main navigation",
|
||||
"mobileNav": "Mobile navigation"
|
||||
},
|
||||
"chat": {
|
||||
"close": "Close",
|
||||
"aiuiConnected": "AIUI connected",
|
||||
"closeAssistant": "Close AI Assistant",
|
||||
"loadingAssistant": "Loading AI assistant...",
|
||||
"aiAssistant": "AI Assistant",
|
||||
"notConfigured": "AI Assistant needs to be enabled before use.",
|
||||
"deployCta": "Go to Settings to configure your AI provider API key, then return here to start chatting."
|
||||
},
|
||||
"web5": {
|
||||
"title": "Web5",
|
||||
"subtitle": "Decentralized identity and data protocols",
|
||||
"profitsHelper": "Earn networking profits by hosting decentralized services",
|
||||
"networkingProfits": "Networking Profits",
|
||||
"didStatus": "DID Status",
|
||||
"walletConnection": "Wallet Connection",
|
||||
"wallet": "Wallet",
|
||||
"walletSubtitle": "On-chain, Lightning & Ecash",
|
||||
"nostrRelays": "Nostr Relays",
|
||||
"connectedNodes": "Connected Nodes",
|
||||
"bitcoinDomains": "Bitcoin Domain Names",
|
||||
"domainsSubtitle": "NIP-05 verified identities",
|
||||
"copyDid": "Copy",
|
||||
"viewDidDocument": "View",
|
||||
"createDid": "Create DID",
|
||||
"creatingDid": "Creating...",
|
||||
"manageDomains": "Manage Domains",
|
||||
"relaysConnected": "{count} connected",
|
||||
"peersKnown": "{count} peer(s) known",
|
||||
"findNodes": "Find Nodes",
|
||||
"sendMessage": "Send Message",
|
||||
"sendMessageTitle": "Send Message (over Tor)",
|
||||
"to": "To",
|
||||
"selectPeer": "Select a peer...",
|
||||
"message": "Message",
|
||||
"messagePlaceholder": "Type your message...",
|
||||
"didDocument": "DID Document",
|
||||
"addContent": "Add Content",
|
||||
"addContentTitle": "Add Content",
|
||||
"createIdentity": "Create Identity",
|
||||
"createIdentityTitle": "Create Identity",
|
||||
"deleteIdentity": "Delete Identity",
|
||||
"deleteIdentityTitle": "Delete Identity",
|
||||
"sendBitcoin": "Send Bitcoin",
|
||||
"sendBitcoinTitle": "Send Bitcoin",
|
||||
"receiveBitcoin": "Receive Bitcoin",
|
||||
"receiveBitcoinTitle": "Receive Bitcoin",
|
||||
"domains": "Domains",
|
||||
"domainsTitle": "Domains",
|
||||
"relays": "Relays",
|
||||
"relaysTitle": "Relays",
|
||||
"totalEarned": "Total Earned",
|
||||
"monthlyAvg": "Monthly Avg",
|
||||
"ecashBalance": "Ecash Balance",
|
||||
"onChain": "On-chain",
|
||||
"lightning": "Lightning",
|
||||
"ecash": "Ecash",
|
||||
"identityName": "Identity Name",
|
||||
"identityNamePlaceholder": "Enter identity name",
|
||||
"contentTitle": "Title",
|
||||
"contentTitlePlaceholder": "Enter content title",
|
||||
"amount": "Amount",
|
||||
"amountPlaceholder": "Enter amount in sats",
|
||||
"address": "Address",
|
||||
"addressPlaceholder": "Enter Bitcoin address",
|
||||
"deleteIdentityConfirm": "Are you sure you want to delete this identity? This action cannot be undone.",
|
||||
"confirm": "Confirm",
|
||||
"noRelays": "No relays connected",
|
||||
"noDomains": "No domains configured",
|
||||
"addRelay": "Add Relay",
|
||||
"addDomain": "Add Domain",
|
||||
"relayUrl": "Relay URL",
|
||||
"relayUrlPlaceholder": "wss://relay.example.com",
|
||||
"domainName": "Domain Name",
|
||||
"domainNamePlaceholder": "user{'@'}example.com",
|
||||
"peerNodesDescription": "Peer nodes discovered via Nostr. Messages sent over Tor.",
|
||||
"nodeVisibility": "Node Visibility",
|
||||
"nodeVisibilityDesc": "Control how other nodes can discover you",
|
||||
"yourTorAddress": "Your Tor address",
|
||||
"discoverableWarning": "Making your node discoverable lets other Archipelago users find and connect with you.",
|
||||
"noPeers": "No peers yet. Add a peer manually or use Discover to find nodes on Nostr.",
|
||||
"noMessages": "No messages yet. Messages from peers will appear here.",
|
||||
"noRequests": "No pending connection requests.",
|
||||
"accept": "Accept",
|
||||
"reject": "Reject",
|
||||
"discovering": "Discovering...",
|
||||
"discoverNodes": "Discover Nodes on Nostr",
|
||||
"refreshMessages": "Refresh Messages",
|
||||
"refreshRequests": "Refresh Requests",
|
||||
"torServices": "Tor Services",
|
||||
"torServicesDesc": "Hidden services exposing your apps over Tor",
|
||||
"noTorServices": "No Tor hidden services configured.",
|
||||
"content": "Content",
|
||||
"contentDesc": "Share and browse content with peers over Tor",
|
||||
"noSharedContent": "No shared content",
|
||||
"addFilesToShare": "Add files to share with connected peers.",
|
||||
"browse": "Browse",
|
||||
"connectingToPeer": "Connecting to peer over Tor...",
|
||||
"selectPeerToBrowse": "Select a peer to browse",
|
||||
"choosePeerDesc": "Choose a connected peer to see their shared content.",
|
||||
"peerNoContent": "This peer has no shared content.",
|
||||
"identities": "Identities",
|
||||
"identitiesDesc": "Sovereign digital identities (DID:key)",
|
||||
"noIdentities": "No identities yet",
|
||||
"createFirstIdentity": "Create your first sovereign digital identity.",
|
||||
"deleting": "Deleting...",
|
||||
"decentralizedWebNode": "Decentralized Web Node",
|
||||
"dwnDescription": "Personal data store with DID-based access control",
|
||||
"manageDwn": "Manage DWN",
|
||||
"syncing": "Syncing...",
|
||||
"syncNow": "Sync Now",
|
||||
"verifiableCredentials": "Verifiable Credentials",
|
||||
"verifiableCredentialsDesc": "Issue and manage W3C Verifiable Credentials",
|
||||
"noCredentials": "No credentials issued yet",
|
||||
"messageSent": "Message sent over Tor!",
|
||||
"failedToSend": "Failed to send",
|
||||
"pasteInvoice": "Paste a Lightning invoice (BOLT11)",
|
||||
"enterBitcoinAddress": "Enter a Bitcoin address",
|
||||
"sendFailed": "Send failed",
|
||||
"broadcastViaHwWallet": "Broadcast via hardware wallet",
|
||||
"broadcastFailed": "Broadcast failed",
|
||||
"psbtCopied": "PSBT copied!",
|
||||
"enterAmount": "Enter an amount",
|
||||
"pasteEcashToken": "Paste an ecash token",
|
||||
"receiveFailed": "Receive failed",
|
||||
"ecashTokenCopied": "Ecash token copied",
|
||||
"contentAdded": "Content added",
|
||||
"failedToAddContent": "Failed to add content",
|
||||
"contentRemoved": "Content removed",
|
||||
"failedToRemoveContent": "Failed to remove content",
|
||||
"failedToUpdatePricing": "Failed to update pricing",
|
||||
"failedToUpdatePrice": "Failed to update price",
|
||||
"failedToConnectPeer": "Failed to connect to peer",
|
||||
"onionAddressCopied": "Onion address copied",
|
||||
"streamUrlCopied": "Stream URL copied",
|
||||
"playerError": "Unable to load media. The content may only be accessible over Tor.",
|
||||
"connectionAccepted": "Connection accepted",
|
||||
"failedToAcceptRequest": "Failed to accept request",
|
||||
"requestRejected": "Request rejected",
|
||||
"failedToRejectRequest": "Failed to reject request",
|
||||
"visibilitySetTo": "Visibility set to {level}",
|
||||
"failedToUpdateVisibility": "Failed to update visibility",
|
||||
"didCopied": "DID copied to clipboard",
|
||||
"defaultIdentityUpdated": "Default identity updated",
|
||||
"failedToSetDefault": "Failed to set default",
|
||||
"identityCreated": "Identity created",
|
||||
"failedToCreateIdentity": "Failed to create identity",
|
||||
"identityDeleted": "Identity deleted",
|
||||
"failedToDeleteIdentity": "Failed to delete identity",
|
||||
"registrationFailed": "Registration failed",
|
||||
"removeFailed": "Remove failed",
|
||||
"failedToAddRelay": "Failed to add relay",
|
||||
"failedToRemoveRelay": "Failed to remove relay",
|
||||
"failedToToggleRelay": "Failed to toggle relay",
|
||||
"downloadUrlCopied": "Download URL copied",
|
||||
"hardwareWalletDetected": "Hardware Wallet Detected",
|
||||
"namesRegistered": "Names Registered",
|
||||
"expiringSoon": "Expiring Soon",
|
||||
"nostrRelaysDesc": "Decentralized social networking relays",
|
||||
"relaysConnectedLabel": "Relays Connected",
|
||||
"totalRelays": "Total Relays",
|
||||
"freeAccessDesc": "Available to all peers for free",
|
||||
"peersOnlyAccessDesc": "Available only to connected peers",
|
||||
"signWithHwWallet": "Sign with Hardware Wallet",
|
||||
"createsPsbt": "Creates a PSBT for external signing",
|
||||
"generateFreshAddress": "Generate a fresh Bitcoin address",
|
||||
"registerNewName": "Register New Name",
|
||||
"verifyNip05": "Verify NIP-05",
|
||||
"peers": "Peers",
|
||||
"messages": "Messages",
|
||||
"requests": "Requests",
|
||||
"myContent": "My Content",
|
||||
"browsePeers": "Browse Peers",
|
||||
"verified": "Verified",
|
||||
"invalid": "Invalid",
|
||||
"stream": "Stream",
|
||||
"download": "Download"
|
||||
},
|
||||
"appDetails": {
|
||||
"backToApps": "Back to My Apps",
|
||||
"backToStore": "Back to App Store",
|
||||
"screenshots": "Screenshots",
|
||||
"screenshotPlaceholder": "Screenshot placeholders - images coming soon",
|
||||
"about": "About {name}",
|
||||
"features": "Features",
|
||||
"information": "Information",
|
||||
"requirements": "Requirements",
|
||||
"ram": "RAM",
|
||||
"ramDesc": "Minimum 512MB",
|
||||
"storage": "Storage",
|
||||
"storageDesc": "~100MB",
|
||||
"links": "Links",
|
||||
"website": "Website",
|
||||
"sourceCode": "Source Code",
|
||||
"documentation": "Documentation",
|
||||
"services": "Services",
|
||||
"guardian": "Guardian",
|
||||
"gateway": "Gateway",
|
||||
"access": "Access",
|
||||
"lan": "LAN",
|
||||
"tor": "Tor",
|
||||
"requiresTor": "Requires Tor Browser",
|
||||
"channels": "Channels",
|
||||
"uninstallTitle": "Uninstall App?",
|
||||
"uninstallConfirm": "Are you sure you want to uninstall {name}? This will remove the app and stop its container.",
|
||||
"notFoundTitle": "App Not Found",
|
||||
"notFoundMessage": "The requested application could not be found",
|
||||
"installed": "Installed",
|
||||
"channels": "Channels",
|
||||
"noLaunchUrl": "No launch URL available for this app yet"
|
||||
},
|
||||
"containerDetails": {
|
||||
"back": "Back",
|
||||
"subtitle": "Container details and management",
|
||||
"containerInfo": "Container Information",
|
||||
"actions": "Actions",
|
||||
"logs": "Logs",
|
||||
"containerId": "Container ID",
|
||||
"image": "Image",
|
||||
"state": "State",
|
||||
"created": "Created",
|
||||
"startContainer": "Start Container",
|
||||
"stopContainer": "Stop Container",
|
||||
"loadingLogs": "Loading logs...",
|
||||
"noLogs": "No logs available"
|
||||
},
|
||||
"marketplaceDetails": {
|
||||
"backToStore": "Back to App Store",
|
||||
"screenshots": "Screenshots",
|
||||
"screenshotPlaceholder": "Screenshot placeholders - images coming soon",
|
||||
"about": "About {name}",
|
||||
"features": "Features",
|
||||
"information": "Information",
|
||||
"requirements": "Requirements",
|
||||
"noRequirements": "No additional dependencies required",
|
||||
"installRequirements": "Install Requirements",
|
||||
"links": "Links",
|
||||
"downloadPackage": "Download Package",
|
||||
"installed": "Installed",
|
||||
"notInstalled": "Not Installed",
|
||||
"open": "Open",
|
||||
"loadingDetails": "Loading app details...",
|
||||
"notFoundTitle": "App Not Found",
|
||||
"notFoundMessage": "The requested application could not be found in the marketplace",
|
||||
"installFailed": "Installation Failed",
|
||||
"depRunning": "Running",
|
||||
"depStopped": "Installed but stopped",
|
||||
"depNotInstalled": "Not installed"
|
||||
},
|
||||
"goalDetail": {
|
||||
"backToGoals": "Back to Goals",
|
||||
"notFound": "Goal not found.",
|
||||
"stepOf": "Step {current} of {total}",
|
||||
"notStarted": "Not Started",
|
||||
"inProgress": "In Progress",
|
||||
"completed": "Completed",
|
||||
"syncTitle": "Sovereignty takes a little patience",
|
||||
"syncMessage": "Your Bitcoin node is syncing the entire blockchain so you don't have to trust anyone else. This takes 2-3 days on first run. Meanwhile, you can explore your node, set up your identity, or back up your keys.",
|
||||
"installApp": "Install {name}",
|
||||
"openAndConfigure": "Open & Configure",
|
||||
"iveDoneThis": "I've Done This",
|
||||
"complete": "Complete",
|
||||
"allSet": "All Set!",
|
||||
"goalReady": "{title} is ready to go.",
|
||||
"viewMyServices": "View My Services"
|
||||
},
|
||||
"monitoring": {
|
||||
"title": "Monitoring",
|
||||
"subtitle": "Real-time system metrics and container resource usage",
|
||||
"cpuUsage": "CPU Usage (%)",
|
||||
"memoryUsage": "Memory Usage (%)",
|
||||
"networkIo": "Network I/O (bytes)",
|
||||
"rpcLatency": "RPC Latency (ms)",
|
||||
"alertHistory": "Alert History",
|
||||
"hideConfig": "Hide Config",
|
||||
"noAlerts": "No alerts fired",
|
||||
"containerResources": "Container Resources",
|
||||
"noContainerMetrics": "No container metrics available",
|
||||
"systemHealth": "System Health",
|
||||
"load": "Load:",
|
||||
"exportCsv": "Export CSV",
|
||||
"exportJson": "Export JSON",
|
||||
"diskUsage": "Disk Usage",
|
||||
"ramUsage": "RAM Usage",
|
||||
"containerCrash": "Container Crash",
|
||||
"rpcLatencySpike": "RPC Latency Spike",
|
||||
"sslCertExpiry": "SSL Cert Expiry",
|
||||
"refreshFooter": "Refreshing every 5 seconds",
|
||||
"wsConnections": "WS connections: {count}",
|
||||
"cpu": "CPU",
|
||||
"memory": "Memory",
|
||||
"network": "Network"
|
||||
},
|
||||
"systemUpdate": {
|
||||
"title": "System Update",
|
||||
"subtitle": "Manage software updates for your Archipelago node",
|
||||
"currentSystem": "Current System",
|
||||
"updateAvailable": "Update Available",
|
||||
"upToDate": "System is up to date",
|
||||
"downloading": "Downloading Update...",
|
||||
"applying": "Applying Update...",
|
||||
"updateSchedule": "Update Schedule",
|
||||
"actions": "Actions",
|
||||
"lastChecked": "Last Checked",
|
||||
"new": "New",
|
||||
"changelog": "Changelog",
|
||||
"componentsToUpdate": "{count} component(s) to update",
|
||||
"manualOnly": "Manual Only",
|
||||
"manualOnlyDesc": "Never check automatically. You control when to check and install updates.",
|
||||
"dailyCheck": "Daily Check",
|
||||
"dailyCheckDesc": "Check for updates once per day. You decide when to install.",
|
||||
"autoApply": "Auto-Apply",
|
||||
"autoApplyDesc": "Check daily and automatically install updates at 3 AM. Service restarts as needed.",
|
||||
"downloadUpdate": "Download Update",
|
||||
"applyUpdate": "Apply Update",
|
||||
"checkForUpdates": "Check for Updates",
|
||||
"checking": "Checking...",
|
||||
"rollback": "Rollback to Previous",
|
||||
"backToSettings": "Back to Settings",
|
||||
"percentComplete": "{percent}% complete",
|
||||
"applyWarning": "Installing components and restarting services. Do not power off.",
|
||||
"applyTitle": "Apply Update?",
|
||||
"applyMessage": "The backend service will restart. This may take a moment.",
|
||||
"rollbackTitle": "Rollback Version?",
|
||||
"rollbackMessage": "This will restore the previous version. The backend service will restart.",
|
||||
"applyNow": "Apply Now",
|
||||
"rollbackButton": "Rollback",
|
||||
"upToDateMessage": "Your system is up to date. No updates available. Your system is running the latest version.",
|
||||
"checkFailed": "Failed to check for updates. Check your internet connection.",
|
||||
"downloadSuccess": "Downloaded {count} component(s) ({size}MB)",
|
||||
"downloadFailed": "Download failed. Please try again.",
|
||||
"applySuccess": "Update applied. The service will restart momentarily.",
|
||||
"applyFailed": "Failed to apply update. You can try again or rollback.",
|
||||
"rollbackSuccess": "Rolled back to previous version. Service will restart.",
|
||||
"rollbackFailed": "Rollback failed."
|
||||
},
|
||||
"kiosk": {
|
||||
"pressEsc": "Press ESC to exit",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"escHint": "Press ESC to exit apps",
|
||||
"navHint": "Use arrow keys to navigate"
|
||||
},
|
||||
"kioskRecovery": {
|
||||
"title": "Archipelago Recovery",
|
||||
"subtitle": "Kiosk failsafe — no authentication required",
|
||||
"serverAddress": "Server Address",
|
||||
"webUi": "Web UI: http://{address}",
|
||||
"scanForMobile": "Scan for mobile access",
|
||||
"backend": "Backend",
|
||||
"unreachable": "Unreachable",
|
||||
"containers": "Containers",
|
||||
"goToLogin": "Go to Login",
|
||||
"lastChecked": "Last checked: {time}"
|
||||
}
|
||||
}
|
||||
706
neode-ui/src/locales/es.json
Normal file
706
neode-ui/src/locales/es.json
Normal file
@@ -0,0 +1,706 @@
|
||||
{
|
||||
"common": {
|
||||
"cancel": "Cancelar",
|
||||
"save": "Guardar",
|
||||
"close": "Cerrar",
|
||||
"copy": "Copiar",
|
||||
"copied": "Copiado",
|
||||
"copiedBang": "\u00a1Copiado!",
|
||||
"loading": "Cargando...",
|
||||
"retry": "Reintentar",
|
||||
"refresh": "Actualizar",
|
||||
"install": "Instalar",
|
||||
"installing": "Instalando...",
|
||||
"uninstall": "Desinstalar",
|
||||
"uninstalling": "Desinstalando...",
|
||||
"start": "Iniciar",
|
||||
"stop": "Detener",
|
||||
"restart": "Reiniciar",
|
||||
"launch": "Abrir",
|
||||
"starting": "Iniciando...",
|
||||
"stopping": "Deteniendo...",
|
||||
"send": "Enviar",
|
||||
"sending": "Enviando...",
|
||||
"back": "Volver",
|
||||
"done": "Listo",
|
||||
"manage": "Administrar",
|
||||
"connect": "Conectar",
|
||||
"connecting": "Conectando...",
|
||||
"disconnect": "Desconectar",
|
||||
"running": "en ejecuci\u00f3n",
|
||||
"stopped": "detenido",
|
||||
"exited": "finalizado",
|
||||
"healthy": "Saludable",
|
||||
"elevated": "Elevado",
|
||||
"critical": "Cr\u00edtico",
|
||||
"connected": "Conectado",
|
||||
"disconnected": "Desconectado",
|
||||
"active": "Activo",
|
||||
"inactive": "Inactivo",
|
||||
"synced": "Sincronizado",
|
||||
"enabled": "Habilitado",
|
||||
"disabled": "Deshabilitado",
|
||||
"dismiss": "Descartar",
|
||||
"apply": "Aplicar",
|
||||
"configure": "Configurar",
|
||||
"export": "Exportar",
|
||||
"delete": "Eliminar",
|
||||
"remove": "Quitar",
|
||||
"error": "Error",
|
||||
"version": "Versi\u00f3n",
|
||||
"status": "Estado",
|
||||
"category": "Categor\u00eda",
|
||||
"developer": "Desarrollador",
|
||||
"license": "Licencia",
|
||||
"never": "Nunca",
|
||||
"notAvailable": "No disponible",
|
||||
"goBack": "Regresar",
|
||||
"skipToContent": "Ir al contenido principal",
|
||||
"continue": "Continuar",
|
||||
"verify": "Verificar",
|
||||
"create": "Crear",
|
||||
"restore": "Restaurar",
|
||||
"disabling": "Deshabilitando...",
|
||||
"creating": "Creando...",
|
||||
"restoring": "Restaurando...",
|
||||
"manageUpdates": "Administrar actualizaciones",
|
||||
"enableAll": "Habilitar todo",
|
||||
"networkDiagnostics": "Diagn\u00f3sticos de red",
|
||||
"network": "Red",
|
||||
"saveConfiguration": "Guardar configuraci\u00f3n",
|
||||
"sendTest": "Enviar prueba"
|
||||
},
|
||||
"login": {
|
||||
"title": "Bienvenido a Archipelago",
|
||||
"setupTitle": "Configure su nodo",
|
||||
"twoFactorTitle": "Autenticaci\u00f3n de dos factores",
|
||||
"password": "Contrase\u00f1a",
|
||||
"confirmPassword": "Confirmar contrase\u00f1a",
|
||||
"enterPasswordPlaceholder": "Ingrese su contrase\u00f1a",
|
||||
"enterPasswordSetup": "Ingrese una contrase\u00f1a (m\u00edn. 8 caracteres)",
|
||||
"confirmPasswordPlaceholder": "Confirme su contrase\u00f1a",
|
||||
"setupButton": "Configurar nodo",
|
||||
"settingUp": "Configurando...",
|
||||
"loginButton": "Iniciar sesi\u00f3n",
|
||||
"loggingIn": "Iniciando sesi\u00f3n...",
|
||||
"verifyButton": "Verificar",
|
||||
"verifying": "Verificando...",
|
||||
"useAuthCode": "Usar c\u00f3digo de autenticador",
|
||||
"useBackupCode": "Usar un c\u00f3digo de respaldo",
|
||||
"totpInstruction": "Ingrese el c\u00f3digo de 6 d\u00edgitos de su aplicaci\u00f3n de autenticaci\u00f3n",
|
||||
"totpPlaceholder": "000000",
|
||||
"backupCodePlaceholder": "XXXX-XXXX",
|
||||
"serverStarting": "El servidor est\u00e1 iniciando...",
|
||||
"replayIntro": "Repetir introducci\u00f3n",
|
||||
"onboarding": "Configuraci\u00f3n inicial",
|
||||
"resetting": "Restableciendo...",
|
||||
"recoveryNote": "La recuperaci\u00f3n de contrase\u00f1a requiere acceso SSH al servidor.",
|
||||
"errorMinLength": "La contrase\u00f1a debe tener al menos 8 caracteres",
|
||||
"errorMismatch": "Las contrase\u00f1as no coinciden",
|
||||
"errorServerStarting": "El servidor est\u00e1 iniciando. Intente de nuevo en un momento.",
|
||||
"errorSetupFailed": "La configuraci\u00f3n fall\u00f3. Intente de nuevo.",
|
||||
"errorLoginFailed": "Inicio de sesi\u00f3n fallido. Verifique su contrase\u00f1a.",
|
||||
"errorInvalidCode": "C\u00f3digo inv\u00e1lido. Intente de nuevo.",
|
||||
"totpLabel": "C\u00f3digo de autenticaci\u00f3n de dos factores"
|
||||
},
|
||||
"home": {
|
||||
"title": "Bienvenido Noderunner",
|
||||
"subtitle": "Un resumen de su vida soberana",
|
||||
"dashboardTab": "Panel",
|
||||
"setupTab": "Configuraci\u00f3n",
|
||||
"myApps": "Mis aplicaciones",
|
||||
"myAppsDesc": "Administre sus aplicaciones instaladas",
|
||||
"cloud": "Nube",
|
||||
"cloudDesc": "Servicios en la nube y almacenamiento",
|
||||
"network": "Red",
|
||||
"networkDesc": "Infraestructura de red y servicios Web3",
|
||||
"web5": "Web5",
|
||||
"web5Desc": "Identidad descentralizada y protocolos de datos",
|
||||
"system": "Sistema",
|
||||
"quickStartGoals": "Objetivos de inicio r\u00e1pido",
|
||||
"quickStartDesc": "\u00bfNo sabe por d\u00f3nde empezar? Pruebe la configuraci\u00f3n guiada.",
|
||||
"installed": "Instaladas",
|
||||
"runningLabel": "En ejecuci\u00f3n",
|
||||
"storageUsed": "Almacenamiento usado",
|
||||
"folders": "Carpetas",
|
||||
"servicesStatus": "Estado de servicios",
|
||||
"connectivity": "Conectividad",
|
||||
"runningApps": "Aplicaciones activas",
|
||||
"didStatus": "Estado de DID",
|
||||
"dwnSync": "Sincronizaci\u00f3n DWN",
|
||||
"credentials": "Credenciales",
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM",
|
||||
"disk": "Disco",
|
||||
"browseStore": "Explorar tienda",
|
||||
"manageApps": "Administrar aplicaciones",
|
||||
"viewFolders": "Ver carpetas",
|
||||
"uploadFiles": "Subir archivos",
|
||||
"manageNetwork": "Administrar red",
|
||||
"manageWeb5": "Administrar Web5",
|
||||
"openAI": "Abrir asistente de IA",
|
||||
"noApps": "Sin aplicaciones",
|
||||
"allRunning": "Todas activas",
|
||||
"systemMonitoring": "Monitoreo del sistema",
|
||||
"updateAvailable": "Actualizaci\u00f3n disponible: v{version}",
|
||||
"updateNow": "Actualizar ahora",
|
||||
"goToApps": "Ir a aplicaciones",
|
||||
"goToCloud": "Ir a la nube",
|
||||
"goToNetwork": "Ir a la red",
|
||||
"goToWeb5": "Ir a Web5",
|
||||
"goToSettings": "Ir a configuraci\u00f3n"
|
||||
},
|
||||
"apps": {
|
||||
"title": "Mis aplicaciones",
|
||||
"subtitle": "Administre sus aplicaciones instaladas",
|
||||
"searchPlaceholder": "Buscar aplicaciones instaladas...",
|
||||
"noAppsTitle": "No hay aplicaciones instaladas",
|
||||
"noAppsMessage": "Comience explorando la tienda de aplicaciones",
|
||||
"browseAppStore": "Explorar tienda de aplicaciones",
|
||||
"noResults": "No se encontraron aplicaciones para \"{query}\"",
|
||||
"uninstallTitle": "\u00bfDesinstalar aplicaci\u00f3n?",
|
||||
"uninstallConfirm": "\u00bfEst\u00e1 seguro de que desea desinstalar {name}? Esto eliminar\u00e1 la aplicaci\u00f3n y detendr\u00e1 su contenedor.",
|
||||
"dismissError": "Descartar error",
|
||||
"searchLabel": "Buscar aplicaciones instaladas"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Configuraci\u00f3n",
|
||||
"subtitle": "Configure su experiencia en Archipelago",
|
||||
"account": "Cuenta",
|
||||
"interfaceMode": "Modo de interfaz",
|
||||
"claudeAuth": "Autenticaci\u00f3n de Claude",
|
||||
"aiDataAccess": "Acceso a datos de IA",
|
||||
"serverName": "Nombre del servidor",
|
||||
"sessionStatus": "Estado de la sesi\u00f3n",
|
||||
"yourDid": "Su DID",
|
||||
"onionAddress": "Direcci\u00f3n .onion del nodo",
|
||||
"loggedIn": "Sesi\u00f3n iniciada actualmente",
|
||||
"didHelper": "Identificador descentralizado para autenticaci\u00f3n sin contrase\u00f1a",
|
||||
"onionHelper": "Direcci\u00f3n onion para la interfaz del nodo y descubrimiento de pares a trav\u00e9s de Tor",
|
||||
"changePassword": "Cambiar contrase\u00f1a",
|
||||
"enable2fa": "Habilitar 2FA",
|
||||
"disable2fa": "Deshabilitar 2FA",
|
||||
"logout": "Cerrar sesi\u00f3n",
|
||||
"loggingOut": "Cerrando sesi\u00f3n...",
|
||||
"twoFactorAuth": "Autenticaci\u00f3n de dos factores",
|
||||
"twoFaProtect": "Proteja su cuenta con una aplicaci\u00f3n de autenticaci\u00f3n",
|
||||
"changePasswordTitle": "Cambiar contrase\u00f1a",
|
||||
"changePasswordDesc": "Actualiza tanto el inicio de sesi\u00f3n web como el acceso SSH. Use una contrase\u00f1a segura (12+ caracteres, may\u00fasculas, min\u00fasculas, d\u00edgitos, caracteres especiales).",
|
||||
"currentPassword": "Contrase\u00f1a actual",
|
||||
"newPassword": "Nueva contrase\u00f1a",
|
||||
"confirmNewPassword": "Confirmar nueva contrase\u00f1a",
|
||||
"passwordPlaceholder": "12+ caracteres, may\u00fasculas, min\u00fasculas, d\u00edgitos, especiales",
|
||||
"updateSshCheckbox": "Tambi\u00e9n actualizar contrase\u00f1a SSH (recomendado)",
|
||||
"updatePassword": "Actualizar contrase\u00f1a",
|
||||
"updatingPassword": "Actualizando...",
|
||||
"setup2faTitle": "Autenticaci\u00f3n de dos factores",
|
||||
"setup2faPasswordPrompt": "Ingrese su contrase\u00f1a para comenzar la configuraci\u00f3n.",
|
||||
"scanQrCode": "Escanear c\u00f3digo QR",
|
||||
"scanQrInstruction": "Escanee este c\u00f3digo QR con su aplicaci\u00f3n de autenticaci\u00f3n (Google Authenticator, Authy, etc.), luego ingrese el c\u00f3digo de 6 d\u00edgitos.",
|
||||
"manualEntryKey": "Clave de ingreso manual:",
|
||||
"verifyAndEnable": "Verificar y habilitar",
|
||||
"saveBackupCodes": "Guarde sus c\u00f3digos de respaldo",
|
||||
"backupCodesInstruction": "Almacene estos c\u00f3digos de forma segura. Cada uno puede usarse una vez si pierde acceso a su aplicaci\u00f3n de autenticaci\u00f3n.",
|
||||
"copyAllCodes": "Copiar todos los c\u00f3digos",
|
||||
"disable2faTitle": "Deshabilitar autenticaci\u00f3n de dos factores",
|
||||
"disable2faDesc": "Ingrese su contrase\u00f1a y un c\u00f3digo TOTP actual para deshabilitar 2FA.",
|
||||
"authenticatorCode": "C\u00f3digo de autenticador",
|
||||
"webhooks": "Webhooks",
|
||||
"webhooksDesc": "Reciba notificaciones cuando ocurran eventos importantes en su nodo",
|
||||
"webhookUrl": "URL del webhook",
|
||||
"webhookUrlPlaceholder": "https://ejemplo.com/webhook",
|
||||
"webhookSecret": "Secreto (para firma HMAC)",
|
||||
"webhookSecretPlaceholder": "Secreto compartido opcional",
|
||||
"webhookEvents": "Eventos",
|
||||
"containerCrash": "Fallo de contenedor",
|
||||
"updateAvailableEvent": "Actualizaci\u00f3n disponible",
|
||||
"diskWarning": "Advertencia de disco",
|
||||
"backupComplete": "Respaldo completado",
|
||||
"saveWebhook": "Guardar",
|
||||
"savingWebhook": "Guardando...",
|
||||
"testWebhook": "Probar",
|
||||
"testingWebhook": "Probando...",
|
||||
"webhookSaved": "Configuraci\u00f3n de webhook guardada",
|
||||
"webhookTestSent": "Webhook de prueba enviado exitosamente",
|
||||
"systemUpdates": "Actualizaciones del sistema",
|
||||
"backup": "Respaldo y restauraci\u00f3n",
|
||||
"backupDesc": "Respalde los datos de su nodo en almacenamiento externo",
|
||||
"createBackup": "Crear respaldo",
|
||||
"creatingBackup": "Creando...",
|
||||
"restoreBackup": "Restaurar respaldo",
|
||||
"deleteBackup": "Eliminar respaldo",
|
||||
"backupCreated": "Respaldo creado exitosamente",
|
||||
"sendMessage": "Enviar mensaje",
|
||||
"sendMessageTitle": "Enviar mensaje de difusi\u00f3n",
|
||||
"messagePlaceholder": "Escriba su mensaje...",
|
||||
"messageSent": "Mensaje enviado",
|
||||
"claudeConnected": "Conectado a Claude",
|
||||
"claudeDisconnected": "No conectado",
|
||||
"claudeApiKey": "Clave API",
|
||||
"claudeApiKeyPlaceholder": "Ingrese su clave API de Anthropic",
|
||||
"claudeSave": "Guardar clave",
|
||||
"advancedMode": "Modo avanzado",
|
||||
"beginnerMode": "Modo principiante",
|
||||
"advancedModeDesc": "Mostrar todos los controles del sistema y herramientas de desarrollo",
|
||||
"beginnerModeDesc": "Interfaz simplificada con experiencia guiada",
|
||||
"networkSettings": "Configuraci\u00f3n de red",
|
||||
"torEnabled": "Tor habilitado",
|
||||
"torAddress": "Direcci\u00f3n Tor",
|
||||
"interfaceModeDesc": "Elija c\u00f3mo desea interactuar con su nodo.",
|
||||
"claudeAuthDesc": "Conecte su cuenta de Claude Max para habilitar las funciones de chat con IA.",
|
||||
"connectionStatus": "Estado de conexi\u00f3n",
|
||||
"notConnected": "No conectado",
|
||||
"reAuthenticate": "Reautenticar",
|
||||
"loginWithClaude": "Iniciar sesi\u00f3n con Claude",
|
||||
"aiDataAccessDesc": "Controle a qu\u00e9 datos puede acceder el asistente de IA. Todas las categor\u00edas est\u00e1n desactivadas por defecto.",
|
||||
"enableAllDesc": "Otorgar acceso a todas las categor\u00edas de datos a la vez",
|
||||
"systemUpdatesDesc": "Buscar e instalar actualizaciones de software",
|
||||
"webhookNotifications": "Notificaciones por webhook",
|
||||
"webhookNotificationsDesc": "Reciba notificaciones push para eventos cr\u00edticos a trav\u00e9s de webhook",
|
||||
"enableWebhooks": "Habilitar webhooks",
|
||||
"disableWebhooks": "Deshabilitar webhooks",
|
||||
"webhookUrlLabel": "URL del webhook",
|
||||
"webhookSecretLabel": "Secreto (opcional, para firma HMAC-SHA256)",
|
||||
"eventsToNotify": "Eventos a notificar",
|
||||
"containerCrashDesc": "Un contenedor en ejecuci\u00f3n se detiene inesperadamente",
|
||||
"updateAvailableDesc": "Una nueva actualizaci\u00f3n del sistema o aplicaci\u00f3n est\u00e1 lista",
|
||||
"diskWarningDesc": "El uso de disco supera el umbral de advertencia",
|
||||
"backupCompleteDesc": "Un respaldo programado o manual ha finalizado",
|
||||
"backupRestoreDesc": "Respaldos cifrados de su identidad, configuraci\u00f3n y datos",
|
||||
"loadingBackups": "Cargando respaldos...",
|
||||
"noBackups": "A\u00fan no hay respaldos. Cree uno para proteger los datos de su nodo.",
|
||||
"systemBackup": "Respaldo del sistema",
|
||||
"createEncryptedBackup": "Crear respaldo cifrado",
|
||||
"encryptionPassphrase": "Frase de cifrado",
|
||||
"enterPassphrase": "Ingrese una frase segura",
|
||||
"descriptionOptional": "Descripci\u00f3n (opcional)",
|
||||
"descriptionPlaceholder": "Ej. Antes de actualizar",
|
||||
"restoreBackupTitle": "Restaurar respaldo",
|
||||
"restoreWarning": "Esto sobrescribir\u00e1 los datos actuales del nodo. Aseg\u00farese de tener la frase de cifrado correcta.",
|
||||
"enterBackupPassphrase": "Ingrese la frase de cifrado del respaldo",
|
||||
"networkDesc": "Conectividad de red, UPnP y diagn\u00f3sticos",
|
||||
"webhookSecretPlaceholderFull": "Secreto compartido para firma de carga \u00fatil",
|
||||
"backupCreatedSuccess": "Respaldo creado exitosamente",
|
||||
"backupCreateFailed": "Error al crear el respaldo",
|
||||
"backupVerifiedOk": "Respaldo verificado \u2014 integridad correcta",
|
||||
"backupVerifyFailed": "La verificaci\u00f3n fall\u00f3: {error}",
|
||||
"backupVerifyRequestFailed": "La solicitud de verificaci\u00f3n fall\u00f3",
|
||||
"backupRestored": "Respaldo restaurado. Puede ser necesario reiniciar.",
|
||||
"backupRestoreFailed": "La restauraci\u00f3n fall\u00f3 \u2014 verifique la frase de cifrado",
|
||||
"backupDeleted": "Respaldo eliminado",
|
||||
"backupDeleteFailed": "Error al eliminar el respaldo",
|
||||
"noUsbDrives": "No se encontraron unidades USB montadas. Inserte y monte una unidad USB primero.",
|
||||
"backupCopiedToUsb": "Respaldo copiado a {path}",
|
||||
"backupUsbFailed": "Error al copiar el respaldo a USB",
|
||||
"deleteBackupConfirm": "\u00bfEliminar este respaldo permanentemente?",
|
||||
"verifyPassphrasePrompt": "Ingrese la frase de cifrado del respaldo para verificar:",
|
||||
"webhookSaveFailed": "Error al guardar la configuraci\u00f3n del webhook",
|
||||
"webhookTestFailed": "La prueba fall\u00f3: el webhook no se envi\u00f3",
|
||||
"webhookSendFailed": "Error al enviar el webhook de prueba",
|
||||
"passwordAllFieldsRequired": "Todos los campos son obligatorios",
|
||||
"passwordMismatch": "Las nuevas contrase\u00f1as no coinciden",
|
||||
"passwordUpdatedSuccess": "Contrase\u00f1a actualizada exitosamente. Use la nueva contrase\u00f1a para iniciar sesi\u00f3n y SSH.",
|
||||
"passwordChangeFailed": "Error al cambiar la contrase\u00f1a",
|
||||
"passwordMinLength": "La contrase\u00f1a debe tener al menos 12 caracteres",
|
||||
"passwordNeedUppercase": "La contrase\u00f1a debe contener al menos una letra may\u00fascula",
|
||||
"passwordNeedLowercase": "La contrase\u00f1a debe contener al menos una letra min\u00fascula",
|
||||
"passwordNeedDigit": "La contrase\u00f1a debe contener al menos un d\u00edgito",
|
||||
"passwordNeedSpecial": "La contrase\u00f1a debe contener al menos un car\u00e1cter especial (!@#$%^&* etc.)",
|
||||
"setupFailed": "La configuraci\u00f3n fall\u00f3",
|
||||
"verificationFailed": "La verificaci\u00f3n fall\u00f3",
|
||||
"disableFailed": "Error al deshabilitar 2FA",
|
||||
"copyToUsb": "Copiar a USB",
|
||||
"diskSpaceWarning": "Advertencia de espacio en disco",
|
||||
"modeEasy": "F\u00e1cil",
|
||||
"modeEasyDesc": "Interfaz basada en objetivos. Elija lo que desea hacer y el sistema se encarga del resto.",
|
||||
"modePro": "Pro",
|
||||
"modeProDesc": "Control total sobre todos los servicios. Configure todo manualmente con todos los detalles t\u00e9cnicos.",
|
||||
"modeChat": "AIUI",
|
||||
"modeChatDesc": "Interfaz de IA conversacional. Administre su nodo mediante lenguaje natural. Pr\u00f3ximamente."
|
||||
},
|
||||
"marketplace": {
|
||||
"title": "Tienda de aplicaciones",
|
||||
"subtitle": "Descubra e instale aplicaciones para su nueva vida soberana",
|
||||
"curatedTab": "Seleccionadas",
|
||||
"communityTab": "Comunidad",
|
||||
"nostrCommunityTab": "Comunidad Nostr",
|
||||
"filterByCategory": "Filtrar por categor\u00eda",
|
||||
"searchPlaceholder": "Buscar aplicaciones...",
|
||||
"downloading": "Descargando...",
|
||||
"alreadyInstalled": "Ya instalada",
|
||||
"queryingRelays": "Consultando relays de Nostr en busca de aplicaciones...",
|
||||
"noCommunityApps": "A\u00fan no se han descubierto aplicaciones de la comunidad.",
|
||||
"noResults": "No se encontraron aplicaciones en {category} que coincidan con \"{query}\"",
|
||||
"noResultsCategory": "No se encontraron aplicaciones en {category}",
|
||||
"noResultsSearch": "No se encontraron aplicaciones para \"{query}\"",
|
||||
"all": "Todas",
|
||||
"community": "Comunidad",
|
||||
"commerce": "Comercio",
|
||||
"money": "Dinero",
|
||||
"data": "Datos",
|
||||
"homeCategory": "Hogar",
|
||||
"auto": "Automatizaci\u00f3n",
|
||||
"networking": "Redes",
|
||||
"other": "Otras",
|
||||
"searchApps": "Buscar aplicaciones",
|
||||
"percentComplete": "{percent}% completado"
|
||||
},
|
||||
"dashboard": {
|
||||
"mainNav": "Navegaci\u00f3n principal",
|
||||
"mobileNav": "Navegaci\u00f3n m\u00f3vil"
|
||||
},
|
||||
"chat": {
|
||||
"close": "Cerrar",
|
||||
"aiuiConnected": "AIUI conectado",
|
||||
"closeAssistant": "Cerrar asistente de IA",
|
||||
"loadingAssistant": "Cargando asistente de IA...",
|
||||
"aiAssistant": "Asistente de IA",
|
||||
"notConfigured": "El asistente de IA necesita ser habilitado antes de usarse.",
|
||||
"deployCta": "Vaya a Configuraci\u00f3n para configurar su clave API del proveedor de IA, luego regrese aqu\u00ed para comenzar a chatear."
|
||||
},
|
||||
"web5": {
|
||||
"title": "Web5",
|
||||
"subtitle": "Identidad descentralizada y protocolos de datos",
|
||||
"profitsHelper": "Obtenga ganancias de red al alojar servicios descentralizados",
|
||||
"networkingProfits": "Ganancias de red",
|
||||
"didStatus": "Estado de DID",
|
||||
"walletConnection": "Conexi\u00f3n de billetera",
|
||||
"wallet": "Billetera",
|
||||
"walletSubtitle": "On-chain, Lightning y Ecash",
|
||||
"nostrRelays": "Relays de Nostr",
|
||||
"connectedNodes": "Nodos conectados",
|
||||
"bitcoinDomains": "Nombres de dominio Bitcoin",
|
||||
"domainsSubtitle": "Identidades verificadas NIP-05",
|
||||
"copyDid": "Copiar",
|
||||
"viewDidDocument": "Ver",
|
||||
"createDid": "Crear DID",
|
||||
"creatingDid": "Creando...",
|
||||
"manageDomains": "Administrar dominios",
|
||||
"relaysConnected": "{count} conectados",
|
||||
"peersKnown": "{count} par(es) conocido(s)",
|
||||
"findNodes": "Buscar nodos",
|
||||
"sendMessage": "Enviar mensaje",
|
||||
"sendMessageTitle": "Enviar mensaje (a trav\u00e9s de Tor)",
|
||||
"to": "Para",
|
||||
"selectPeer": "Seleccione un par...",
|
||||
"message": "Mensaje",
|
||||
"messagePlaceholder": "Escriba su mensaje...",
|
||||
"didDocument": "Documento DID",
|
||||
"addContent": "Agregar contenido",
|
||||
"addContentTitle": "Agregar contenido",
|
||||
"createIdentity": "Crear identidad",
|
||||
"createIdentityTitle": "Crear identidad",
|
||||
"deleteIdentity": "Eliminar identidad",
|
||||
"deleteIdentityTitle": "Eliminar identidad",
|
||||
"sendBitcoin": "Enviar Bitcoin",
|
||||
"sendBitcoinTitle": "Enviar Bitcoin",
|
||||
"receiveBitcoin": "Recibir Bitcoin",
|
||||
"receiveBitcoinTitle": "Recibir Bitcoin",
|
||||
"domains": "Dominios",
|
||||
"domainsTitle": "Dominios",
|
||||
"relays": "Relays",
|
||||
"relaysTitle": "Relays",
|
||||
"totalEarned": "Total ganado",
|
||||
"monthlyAvg": "Promedio mensual",
|
||||
"ecashBalance": "Saldo Ecash",
|
||||
"onChain": "On-chain",
|
||||
"lightning": "Lightning",
|
||||
"ecash": "Ecash",
|
||||
"identityName": "Nombre de identidad",
|
||||
"identityNamePlaceholder": "Ingrese el nombre de identidad",
|
||||
"contentTitle": "T\u00edtulo",
|
||||
"contentTitlePlaceholder": "Ingrese el t\u00edtulo del contenido",
|
||||
"amount": "Monto",
|
||||
"amountPlaceholder": "Ingrese el monto en sats",
|
||||
"address": "Direcci\u00f3n",
|
||||
"addressPlaceholder": "Ingrese la direcci\u00f3n Bitcoin",
|
||||
"deleteIdentityConfirm": "\u00bfEst\u00e1 seguro de que desea eliminar esta identidad? Esta acci\u00f3n no se puede deshacer.",
|
||||
"confirm": "Confirmar",
|
||||
"noRelays": "No hay relays conectados",
|
||||
"noDomains": "No hay dominios configurados",
|
||||
"addRelay": "Agregar relay",
|
||||
"addDomain": "Agregar dominio",
|
||||
"relayUrl": "URL del relay",
|
||||
"relayUrlPlaceholder": "wss://relay.ejemplo.com",
|
||||
"domainName": "Nombre de dominio",
|
||||
"domainNamePlaceholder": "usuario{'@'}ejemplo.com",
|
||||
"peerNodesDescription": "Nodos pares descubiertos v\u00eda Nostr. Los mensajes se env\u00edan a trav\u00e9s de Tor.",
|
||||
"nodeVisibility": "Visibilidad del nodo",
|
||||
"nodeVisibilityDesc": "Controle c\u00f3mo otros nodos pueden descubrirle",
|
||||
"yourTorAddress": "Su direcci\u00f3n Tor",
|
||||
"discoverableWarning": "Hacer su nodo descubrible permite que otros usuarios de Archipelago le encuentren y se conecten con usted.",
|
||||
"noPeers": "A\u00fan no hay pares. Agregue un par manualmente o use Descubrir para encontrar nodos en Nostr.",
|
||||
"noMessages": "A\u00fan no hay mensajes. Los mensajes de pares aparecer\u00e1n aqu\u00ed.",
|
||||
"noRequests": "No hay solicitudes de conexi\u00f3n pendientes.",
|
||||
"accept": "Aceptar",
|
||||
"reject": "Rechazar",
|
||||
"discovering": "Descubriendo...",
|
||||
"discoverNodes": "Descubrir nodos en Nostr",
|
||||
"refreshMessages": "Actualizar mensajes",
|
||||
"refreshRequests": "Actualizar solicitudes",
|
||||
"torServices": "Servicios Tor",
|
||||
"torServicesDesc": "Servicios ocultos que exponen sus aplicaciones a trav\u00e9s de Tor",
|
||||
"noTorServices": "No hay servicios ocultos Tor configurados.",
|
||||
"content": "Contenido",
|
||||
"contentDesc": "Comparta y explore contenido con pares a trav\u00e9s de Tor",
|
||||
"noSharedContent": "Sin contenido compartido",
|
||||
"addFilesToShare": "Agregue archivos para compartir con pares conectados.",
|
||||
"browse": "Explorar",
|
||||
"connectingToPeer": "Conectando al par a trav\u00e9s de Tor...",
|
||||
"selectPeerToBrowse": "Seleccione un par para explorar",
|
||||
"choosePeerDesc": "Elija un par conectado para ver su contenido compartido.",
|
||||
"peerNoContent": "Este par no tiene contenido compartido.",
|
||||
"identities": "Identidades",
|
||||
"identitiesDesc": "Identidades digitales soberanas (DID:key)",
|
||||
"noIdentities": "A\u00fan no hay identidades",
|
||||
"createFirstIdentity": "Cree su primera identidad digital soberana.",
|
||||
"deleting": "Eliminando...",
|
||||
"decentralizedWebNode": "Nodo web descentralizado",
|
||||
"dwnDescription": "Almac\u00e9n de datos personal con control de acceso basado en DID",
|
||||
"manageDwn": "Administrar DWN",
|
||||
"syncing": "Sincronizando...",
|
||||
"syncNow": "Sincronizar ahora",
|
||||
"verifiableCredentials": "Credenciales verificables",
|
||||
"verifiableCredentialsDesc": "Emita y administre credenciales verificables W3C",
|
||||
"noCredentials": "A\u00fan no se han emitido credenciales",
|
||||
"messageSent": "\u00a1Mensaje enviado a trav\u00e9s de Tor!",
|
||||
"failedToSend": "Error al enviar",
|
||||
"pasteInvoice": "Pegue una factura Lightning (BOLT11)",
|
||||
"enterBitcoinAddress": "Ingrese una direcci\u00f3n Bitcoin",
|
||||
"sendFailed": "Error al enviar",
|
||||
"broadcastViaHwWallet": "Transmitir v\u00eda billetera de hardware",
|
||||
"broadcastFailed": "Error en la transmisi\u00f3n",
|
||||
"psbtCopied": "\u00a1PSBT copiado!",
|
||||
"enterAmount": "Ingrese un monto",
|
||||
"pasteEcashToken": "Pegue un token Ecash",
|
||||
"receiveFailed": "Error al recibir",
|
||||
"ecashTokenCopied": "Token Ecash copiado",
|
||||
"contentAdded": "Contenido agregado",
|
||||
"failedToAddContent": "Error al agregar contenido",
|
||||
"contentRemoved": "Contenido eliminado",
|
||||
"failedToRemoveContent": "Error al eliminar contenido",
|
||||
"failedToUpdatePricing": "Error al actualizar precios",
|
||||
"failedToUpdatePrice": "Error al actualizar precio",
|
||||
"failedToConnectPeer": "Error al conectar con el par",
|
||||
"onionAddressCopied": "Direcci\u00f3n onion copiada",
|
||||
"streamUrlCopied": "URL de transmisi\u00f3n copiada",
|
||||
"playerError": "No se pudo cargar el contenido multimedia. Es posible que solo sea accesible a trav\u00e9s de Tor.",
|
||||
"connectionAccepted": "Conexi\u00f3n aceptada",
|
||||
"failedToAcceptRequest": "Error al aceptar la solicitud",
|
||||
"requestRejected": "Solicitud rechazada",
|
||||
"failedToRejectRequest": "Error al rechazar la solicitud",
|
||||
"visibilitySetTo": "Visibilidad establecida en {level}",
|
||||
"failedToUpdateVisibility": "Error al actualizar la visibilidad",
|
||||
"didCopied": "DID copiado al portapapeles",
|
||||
"defaultIdentityUpdated": "Identidad predeterminada actualizada",
|
||||
"failedToSetDefault": "Error al establecer como predeterminada",
|
||||
"identityCreated": "Identidad creada",
|
||||
"failedToCreateIdentity": "Error al crear identidad",
|
||||
"identityDeleted": "Identidad eliminada",
|
||||
"failedToDeleteIdentity": "Error al eliminar identidad",
|
||||
"registrationFailed": "Error en el registro",
|
||||
"removeFailed": "Error al eliminar",
|
||||
"failedToAddRelay": "Error al agregar relay",
|
||||
"failedToRemoveRelay": "Error al eliminar relay",
|
||||
"failedToToggleRelay": "Error al cambiar estado del relay",
|
||||
"downloadUrlCopied": "URL de descarga copiada",
|
||||
"hardwareWalletDetected": "Billetera de hardware detectada",
|
||||
"namesRegistered": "Nombres registrados",
|
||||
"expiringSoon": "Pr\u00f3ximos a vencer",
|
||||
"nostrRelaysDesc": "Relays de redes sociales descentralizadas",
|
||||
"relaysConnectedLabel": "Relays conectados",
|
||||
"totalRelays": "Total de relays",
|
||||
"freeAccessDesc": "Disponible para todos los pares de forma gratuita",
|
||||
"peersOnlyAccessDesc": "Disponible solo para pares conectados",
|
||||
"signWithHwWallet": "Firmar con billetera de hardware",
|
||||
"createsPsbt": "Crea un PSBT para firma externa",
|
||||
"generateFreshAddress": "Generar una nueva direcci\u00f3n Bitcoin",
|
||||
"registerNewName": "Registrar nuevo nombre",
|
||||
"verifyNip05": "Verificar NIP-05",
|
||||
"peers": "Pares",
|
||||
"messages": "Mensajes",
|
||||
"requests": "Solicitudes",
|
||||
"myContent": "Mi contenido",
|
||||
"browsePeers": "Explorar pares",
|
||||
"verified": "Verificado",
|
||||
"invalid": "Inv\u00e1lido",
|
||||
"stream": "Transmitir",
|
||||
"download": "Descargar"
|
||||
},
|
||||
"appDetails": {
|
||||
"backToApps": "Volver a mis aplicaciones",
|
||||
"backToStore": "Volver a la tienda",
|
||||
"screenshots": "Capturas de pantalla",
|
||||
"screenshotPlaceholder": "Capturas de pantalla de ejemplo \u2014 im\u00e1genes disponibles pronto",
|
||||
"about": "Acerca de {name}",
|
||||
"features": "Caracter\u00edsticas",
|
||||
"information": "Informaci\u00f3n",
|
||||
"requirements": "Requisitos",
|
||||
"ram": "RAM",
|
||||
"ramDesc": "M\u00ednimo 512MB",
|
||||
"storage": "Almacenamiento",
|
||||
"storageDesc": "~100MB",
|
||||
"links": "Enlaces",
|
||||
"website": "Sitio web",
|
||||
"sourceCode": "C\u00f3digo fuente",
|
||||
"documentation": "Documentaci\u00f3n",
|
||||
"services": "Servicios",
|
||||
"guardian": "Guardi\u00e1n",
|
||||
"gateway": "Gateway",
|
||||
"access": "Acceso",
|
||||
"lan": "LAN",
|
||||
"tor": "Tor",
|
||||
"requiresTor": "Requiere Tor Browser",
|
||||
"channels": "Canales",
|
||||
"uninstallTitle": "\u00bfDesinstalar aplicaci\u00f3n?",
|
||||
"uninstallConfirm": "\u00bfEst\u00e1 seguro de que desea desinstalar {name}? Esto eliminar\u00e1 la aplicaci\u00f3n y detendr\u00e1 su contenedor.",
|
||||
"notFoundTitle": "Aplicaci\u00f3n no encontrada",
|
||||
"notFoundMessage": "No se pudo encontrar la aplicaci\u00f3n solicitada",
|
||||
"installed": "Instalada",
|
||||
"noLaunchUrl": "A\u00fan no hay URL de acceso disponible para esta aplicaci\u00f3n"
|
||||
},
|
||||
"containerDetails": {
|
||||
"back": "Volver",
|
||||
"subtitle": "Detalles y administraci\u00f3n del contenedor",
|
||||
"containerInfo": "Informaci\u00f3n del contenedor",
|
||||
"actions": "Acciones",
|
||||
"logs": "Registros",
|
||||
"containerId": "ID del contenedor",
|
||||
"image": "Imagen",
|
||||
"state": "Estado",
|
||||
"created": "Creado",
|
||||
"startContainer": "Iniciar contenedor",
|
||||
"stopContainer": "Detener contenedor",
|
||||
"loadingLogs": "Cargando registros...",
|
||||
"noLogs": "No hay registros disponibles"
|
||||
},
|
||||
"marketplaceDetails": {
|
||||
"backToStore": "Volver a la tienda",
|
||||
"screenshots": "Capturas de pantalla",
|
||||
"screenshotPlaceholder": "Capturas de pantalla de ejemplo \u2014 im\u00e1genes disponibles pronto",
|
||||
"about": "Acerca de {name}",
|
||||
"features": "Caracter\u00edsticas",
|
||||
"information": "Informaci\u00f3n",
|
||||
"requirements": "Requisitos",
|
||||
"noRequirements": "No se requieren dependencias adicionales",
|
||||
"installRequirements": "Instalar requisitos",
|
||||
"links": "Enlaces",
|
||||
"downloadPackage": "Descargar paquete",
|
||||
"installed": "Instalada",
|
||||
"notInstalled": "No instalada",
|
||||
"open": "Abrir",
|
||||
"loadingDetails": "Cargando detalles de la aplicaci\u00f3n...",
|
||||
"notFoundTitle": "Aplicaci\u00f3n no encontrada",
|
||||
"notFoundMessage": "No se pudo encontrar la aplicaci\u00f3n solicitada en la tienda",
|
||||
"installFailed": "La instalaci\u00f3n fall\u00f3",
|
||||
"depRunning": "En ejecuci\u00f3n",
|
||||
"depStopped": "Instalada pero detenida",
|
||||
"depNotInstalled": "No instalada"
|
||||
},
|
||||
"goalDetail": {
|
||||
"backToGoals": "Volver a objetivos",
|
||||
"notFound": "Objetivo no encontrado.",
|
||||
"stepOf": "Paso {current} de {total}",
|
||||
"notStarted": "No iniciado",
|
||||
"inProgress": "En progreso",
|
||||
"completed": "Completado",
|
||||
"syncTitle": "La soberan\u00eda requiere un poco de paciencia",
|
||||
"syncMessage": "Su nodo Bitcoin est\u00e1 sincronizando toda la cadena de bloques para que no tenga que confiar en nadie m\u00e1s. Esto toma 2\u20133 d\u00edas en la primera ejecuci\u00f3n. Mientras tanto, puede explorar su nodo, configurar su identidad o respaldar sus claves.",
|
||||
"installApp": "Instalar {name}",
|
||||
"openAndConfigure": "Abrir y configurar",
|
||||
"iveDoneThis": "Ya lo hice",
|
||||
"complete": "Completar",
|
||||
"allSet": "\u00a1Todo listo!",
|
||||
"goalReady": "{title} est\u00e1 listo para usar.",
|
||||
"viewMyServices": "Ver mis servicios"
|
||||
},
|
||||
"monitoring": {
|
||||
"title": "Monitoreo",
|
||||
"subtitle": "M\u00e9tricas del sistema en tiempo real y uso de recursos de contenedores",
|
||||
"cpuUsage": "Uso de CPU (%)",
|
||||
"memoryUsage": "Uso de memoria (%)",
|
||||
"networkIo": "E/S de red (bytes)",
|
||||
"rpcLatency": "Latencia RPC (ms)",
|
||||
"alertHistory": "Historial de alertas",
|
||||
"hideConfig": "Ocultar configuraci\u00f3n",
|
||||
"noAlerts": "No se han disparado alertas",
|
||||
"containerResources": "Recursos de contenedores",
|
||||
"noContainerMetrics": "No hay m\u00e9tricas de contenedores disponibles",
|
||||
"systemHealth": "Salud del sistema",
|
||||
"load": "Carga:",
|
||||
"exportCsv": "Exportar CSV",
|
||||
"exportJson": "Exportar JSON",
|
||||
"diskUsage": "Uso de disco",
|
||||
"ramUsage": "Uso de RAM",
|
||||
"containerCrash": "Fallo de contenedor",
|
||||
"rpcLatencySpike": "Pico de latencia RPC",
|
||||
"sslCertExpiry": "Vencimiento de certificado SSL",
|
||||
"refreshFooter": "Actualizando cada 5 segundos",
|
||||
"wsConnections": "Conexiones WS: {count}",
|
||||
"cpu": "CPU",
|
||||
"memory": "Memoria",
|
||||
"network": "Red"
|
||||
},
|
||||
"systemUpdate": {
|
||||
"title": "Actualizaci\u00f3n del sistema",
|
||||
"subtitle": "Administre las actualizaciones de software de su nodo Archipelago",
|
||||
"currentSystem": "Sistema actual",
|
||||
"updateAvailable": "Actualizaci\u00f3n disponible",
|
||||
"upToDate": "El sistema est\u00e1 actualizado",
|
||||
"downloading": "Descargando actualizaci\u00f3n...",
|
||||
"applying": "Aplicando actualizaci\u00f3n...",
|
||||
"updateSchedule": "Programa de actualizaciones",
|
||||
"actions": "Acciones",
|
||||
"lastChecked": "\u00daltima verificaci\u00f3n",
|
||||
"new": "Nuevo",
|
||||
"changelog": "Registro de cambios",
|
||||
"componentsToUpdate": "{count} componente(s) para actualizar",
|
||||
"manualOnly": "Solo manual",
|
||||
"manualOnlyDesc": "Nunca verificar autom\u00e1ticamente. Usted controla cu\u00e1ndo buscar e instalar actualizaciones.",
|
||||
"dailyCheck": "Verificaci\u00f3n diaria",
|
||||
"dailyCheckDesc": "Buscar actualizaciones una vez al d\u00eda. Usted decide cu\u00e1ndo instalar.",
|
||||
"autoApply": "Aplicaci\u00f3n autom\u00e1tica",
|
||||
"autoApplyDesc": "Buscar diariamente y aplicar actualizaciones autom\u00e1ticamente a las 3 AM. Los servicios se reinician seg\u00fan sea necesario.",
|
||||
"downloadUpdate": "Descargar actualizaci\u00f3n",
|
||||
"applyUpdate": "Aplicar actualizaci\u00f3n",
|
||||
"checkForUpdates": "Buscar actualizaciones",
|
||||
"checking": "Verificando...",
|
||||
"rollback": "Revertir a la versi\u00f3n anterior",
|
||||
"backToSettings": "Volver a configuraci\u00f3n",
|
||||
"percentComplete": "{percent}% completado",
|
||||
"applyWarning": "Instalando componentes y reiniciando servicios. No apague el equipo.",
|
||||
"applyTitle": "\u00bfAplicar actualizaci\u00f3n?",
|
||||
"applyMessage": "El servicio del backend se reiniciar\u00e1. Esto puede tomar un momento.",
|
||||
"rollbackTitle": "\u00bfRevertir versi\u00f3n?",
|
||||
"rollbackMessage": "Esto restaurar\u00e1 la versi\u00f3n anterior. El servicio del backend se reiniciar\u00e1.",
|
||||
"applyNow": "Aplicar ahora",
|
||||
"rollbackButton": "Revertir",
|
||||
"upToDateMessage": "Su sistema est\u00e1 actualizado. No hay actualizaciones disponibles. Su sistema est\u00e1 ejecutando la \u00faltima versi\u00f3n.",
|
||||
"checkFailed": "Error al buscar actualizaciones. Verifique su conexi\u00f3n a internet.",
|
||||
"downloadSuccess": "Se descargaron {count} componente(s) ({size}MB)",
|
||||
"downloadFailed": "La descarga fall\u00f3. Intente de nuevo.",
|
||||
"applySuccess": "Actualizaci\u00f3n aplicada. El servicio se reiniciar\u00e1 en un momento.",
|
||||
"applyFailed": "Error al aplicar la actualizaci\u00f3n. Puede intentar de nuevo o revertir.",
|
||||
"rollbackSuccess": "Se revirti\u00f3 a la versi\u00f3n anterior. El servicio se reiniciar\u00e1.",
|
||||
"rollbackFailed": "Error al revertir."
|
||||
},
|
||||
"kiosk": {
|
||||
"pressEsc": "Presione ESC para salir",
|
||||
"online": "En l\u00ednea",
|
||||
"offline": "Sin conexi\u00f3n",
|
||||
"escHint": "Presione ESC para salir de las aplicaciones",
|
||||
"navHint": "Use las teclas de flecha para navegar"
|
||||
},
|
||||
"kioskRecovery": {
|
||||
"title": "Recuperaci\u00f3n de Archipelago",
|
||||
"subtitle": "Modo de recuperaci\u00f3n del kiosco \u2014 no requiere autenticaci\u00f3n",
|
||||
"serverAddress": "Direcci\u00f3n del servidor",
|
||||
"webUi": "Interfaz web: http://{address}",
|
||||
"scanForMobile": "Escanee para acceso m\u00f3vil",
|
||||
"backend": "Backend",
|
||||
"unreachable": "Inaccesible",
|
||||
"containers": "Contenedores",
|
||||
"goToLogin": "Ir a inicio de sesi\u00f3n",
|
||||
"lastChecked": "\u00daltima verificaci\u00f3n: {time}"
|
||||
}
|
||||
}
|
||||
15
neode-ui/src/main.ts
Normal file
15
neode-ui/src/main.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import i18n from './i18n'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(i18n)
|
||||
|
||||
app.mount('#app')
|
||||
154
neode-ui/src/router/__tests__/guards.test.ts
Normal file
154
neode-ui/src/router/__tests__/guards.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
// Mock the app store module
|
||||
const mockStore = {
|
||||
isAuthenticated: false,
|
||||
isConnected: false,
|
||||
isReconnecting: false,
|
||||
needsSessionValidation: vi.fn().mockReturnValue(false),
|
||||
checkSession: vi.fn().mockResolvedValue(false),
|
||||
connectWebSocket: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => mockStore,
|
||||
}))
|
||||
|
||||
const Stub = defineComponent({ template: '<div />' })
|
||||
|
||||
function createTestRouter() {
|
||||
return createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
component: Stub,
|
||||
meta: { public: true },
|
||||
children: [
|
||||
{ path: '', component: Stub },
|
||||
{ path: 'login', name: 'login', component: Stub },
|
||||
{ path: 'onboarding/intro', name: 'onboarding-intro', component: Stub },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
component: Stub,
|
||||
children: [
|
||||
{ path: '', name: 'home', component: Stub },
|
||||
{ path: 'apps', name: 'apps', component: Stub },
|
||||
{ path: 'settings', name: 'settings', component: Stub },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
describe('Router Guards', () => {
|
||||
let router: ReturnType<typeof createTestRouter>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mockStore.isAuthenticated = false
|
||||
mockStore.isConnected = false
|
||||
mockStore.isReconnecting = false
|
||||
mockStore.needsSessionValidation.mockReturnValue(false)
|
||||
mockStore.checkSession.mockResolvedValue(false)
|
||||
router = createTestRouter()
|
||||
|
||||
// Add the same beforeEach guard as the real router
|
||||
router.beforeEach(async (to) => {
|
||||
const isPublic = to.meta.public
|
||||
|
||||
if (isPublic) {
|
||||
if (to.path === '/login' && mockStore.isAuthenticated) {
|
||||
if (mockStore.needsSessionValidation()) {
|
||||
return true
|
||||
}
|
||||
return { name: 'home' }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (mockStore.needsSessionValidation()) {
|
||||
mockStore.checkSession()
|
||||
return true
|
||||
}
|
||||
|
||||
if (!mockStore.isAuthenticated) {
|
||||
const hasSession = await mockStore.checkSession()
|
||||
if (hasSession) return true
|
||||
return '/login'
|
||||
}
|
||||
|
||||
if (!mockStore.isConnected && !mockStore.isReconnecting) {
|
||||
mockStore.connectWebSocket()
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
it('allows unauthenticated access to public routes', async () => {
|
||||
await router.push('/login')
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
})
|
||||
|
||||
it('redirects unauthenticated users from protected routes to login', async () => {
|
||||
mockStore.checkSession.mockResolvedValue(false)
|
||||
await router.push('/dashboard')
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
})
|
||||
|
||||
it('allows authenticated users to access protected routes', async () => {
|
||||
mockStore.isAuthenticated = true
|
||||
await router.push('/dashboard/apps')
|
||||
expect(router.currentRoute.value.path).toBe('/dashboard/apps')
|
||||
})
|
||||
|
||||
it('redirects authenticated users from /login to home', async () => {
|
||||
mockStore.isAuthenticated = true
|
||||
mockStore.needsSessionValidation.mockReturnValue(false)
|
||||
await router.push('/login')
|
||||
expect(router.currentRoute.value.name).toBe('home')
|
||||
})
|
||||
|
||||
it('validates stale session and allows access if valid', async () => {
|
||||
mockStore.isAuthenticated = true
|
||||
mockStore.needsSessionValidation.mockReturnValue(true)
|
||||
await router.push('/dashboard/settings')
|
||||
expect(router.currentRoute.value.path).toBe('/dashboard/settings')
|
||||
expect(mockStore.checkSession).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows access to onboarding routes without auth', async () => {
|
||||
await router.push('/onboarding/intro')
|
||||
expect(router.currentRoute.value.path).toBe('/onboarding/intro')
|
||||
})
|
||||
|
||||
it('triggers WebSocket connection for authenticated users without connection', async () => {
|
||||
mockStore.isAuthenticated = true
|
||||
mockStore.isConnected = false
|
||||
mockStore.isReconnecting = false
|
||||
await router.push('/dashboard')
|
||||
expect(mockStore.connectWebSocket).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not reconnect WebSocket if already connected', async () => {
|
||||
mockStore.isAuthenticated = true
|
||||
mockStore.isConnected = true
|
||||
await router.push('/dashboard')
|
||||
expect(mockStore.connectWebSocket).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not reconnect WebSocket if already reconnecting', async () => {
|
||||
mockStore.isAuthenticated = true
|
||||
mockStore.isConnected = false
|
||||
mockStore.isReconnecting = true
|
||||
await router.push('/dashboard')
|
||||
expect(mockStore.connectWebSocket).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
243
neode-ui/src/router/__tests__/onboarding.test.ts
Normal file
243
neode-ui/src/router/__tests__/onboarding.test.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
// Mock the app store module
|
||||
const mockStore = {
|
||||
isAuthenticated: false,
|
||||
isConnected: false,
|
||||
isReconnecting: false,
|
||||
needsSessionValidation: vi.fn().mockReturnValue(false),
|
||||
checkSession: vi.fn().mockResolvedValue(false),
|
||||
connectWebSocket: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => mockStore,
|
||||
}))
|
||||
|
||||
const Stub = defineComponent({ template: '<div />' })
|
||||
|
||||
function createTestRouter() {
|
||||
return createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
component: Stub,
|
||||
meta: { public: true },
|
||||
children: [
|
||||
{ path: '', component: Stub },
|
||||
{ path: 'login', name: 'login', component: Stub },
|
||||
{ path: 'onboarding/intro', name: 'onboarding-intro', component: Stub },
|
||||
{ path: 'onboarding/options', name: 'onboarding-options', component: Stub },
|
||||
{ path: 'onboarding/path', name: 'onboarding-path', component: Stub },
|
||||
{ path: 'onboarding/did', name: 'onboarding-did', component: Stub },
|
||||
{ path: 'onboarding/identity', name: 'onboarding-identity', component: Stub },
|
||||
{ path: 'onboarding/backup', name: 'onboarding-backup', component: Stub },
|
||||
{ path: 'onboarding/verify', name: 'onboarding-verify', component: Stub },
|
||||
{ path: 'onboarding/done', name: 'onboarding-done', component: Stub },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
component: Stub,
|
||||
children: [
|
||||
{ path: '', name: 'home', component: Stub },
|
||||
{ path: 'apps', name: 'apps', component: Stub },
|
||||
{ path: 'settings', name: 'settings', component: Stub },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
describe('Onboarding Routing Flow', () => {
|
||||
let router: ReturnType<typeof createTestRouter>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mockStore.isAuthenticated = false
|
||||
mockStore.isConnected = false
|
||||
mockStore.isReconnecting = false
|
||||
mockStore.needsSessionValidation.mockReturnValue(false)
|
||||
mockStore.checkSession.mockResolvedValue(false)
|
||||
router = createTestRouter()
|
||||
|
||||
// Add the same beforeEach guard as the real router
|
||||
router.beforeEach(async (to) => {
|
||||
const isPublic = to.meta.public
|
||||
|
||||
if (isPublic) {
|
||||
if (to.path === '/login' && mockStore.isAuthenticated) {
|
||||
if (mockStore.needsSessionValidation()) {
|
||||
return true
|
||||
}
|
||||
return { name: 'home' }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (mockStore.needsSessionValidation()) {
|
||||
mockStore.checkSession()
|
||||
return true
|
||||
}
|
||||
|
||||
if (!mockStore.isAuthenticated) {
|
||||
const hasSession = await mockStore.checkSession()
|
||||
if (hasSession) return true
|
||||
return '/login'
|
||||
}
|
||||
|
||||
if (!mockStore.isConnected && !mockStore.isReconnecting) {
|
||||
mockStore.connectWebSocket()
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
describe('unauthenticated users', () => {
|
||||
it('redirects to /login when accessing /dashboard, not to /onboarding', async () => {
|
||||
mockStore.checkSession.mockResolvedValue(false)
|
||||
await router.push('/dashboard')
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
expect(router.currentRoute.value.path).not.toContain('/onboarding')
|
||||
})
|
||||
|
||||
it('redirects to /login when accessing /dashboard/apps', async () => {
|
||||
mockStore.checkSession.mockResolvedValue(false)
|
||||
await router.push('/dashboard/apps')
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
})
|
||||
|
||||
it('redirects to /login when accessing /dashboard/settings', async () => {
|
||||
mockStore.checkSession.mockResolvedValue(false)
|
||||
await router.push('/dashboard/settings')
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
})
|
||||
})
|
||||
|
||||
describe('onboarding routes are accessible when authenticated', () => {
|
||||
beforeEach(() => {
|
||||
mockStore.isAuthenticated = true
|
||||
})
|
||||
|
||||
it('allows access to /onboarding/intro', async () => {
|
||||
await router.push('/onboarding/intro')
|
||||
expect(router.currentRoute.value.path).toBe('/onboarding/intro')
|
||||
expect(router.currentRoute.value.name).toBe('onboarding-intro')
|
||||
})
|
||||
|
||||
it('allows access to /onboarding/options', async () => {
|
||||
await router.push('/onboarding/options')
|
||||
expect(router.currentRoute.value.path).toBe('/onboarding/options')
|
||||
expect(router.currentRoute.value.name).toBe('onboarding-options')
|
||||
})
|
||||
|
||||
it('allows access to /onboarding/path', async () => {
|
||||
await router.push('/onboarding/path')
|
||||
expect(router.currentRoute.value.path).toBe('/onboarding/path')
|
||||
expect(router.currentRoute.value.name).toBe('onboarding-path')
|
||||
})
|
||||
})
|
||||
|
||||
describe('OnboardingDid route', () => {
|
||||
it('is accessible when authenticated', async () => {
|
||||
mockStore.isAuthenticated = true
|
||||
await router.push('/onboarding/did')
|
||||
expect(router.currentRoute.value.path).toBe('/onboarding/did')
|
||||
expect(router.currentRoute.value.name).toBe('onboarding-did')
|
||||
})
|
||||
|
||||
it('is accessible when unauthenticated (public route)', async () => {
|
||||
mockStore.isAuthenticated = false
|
||||
await router.push('/onboarding/did')
|
||||
expect(router.currentRoute.value.path).toBe('/onboarding/did')
|
||||
expect(router.currentRoute.value.name).toBe('onboarding-did')
|
||||
})
|
||||
})
|
||||
|
||||
describe('post-onboarding navigation to dashboard', () => {
|
||||
it('allows authenticated users to navigate from onboarding to /dashboard', async () => {
|
||||
mockStore.isAuthenticated = true
|
||||
|
||||
// Start at onboarding done page
|
||||
await router.push('/onboarding/done')
|
||||
expect(router.currentRoute.value.path).toBe('/onboarding/done')
|
||||
|
||||
// Navigate to dashboard (simulating post-onboarding completion)
|
||||
await router.push('/dashboard')
|
||||
expect(router.currentRoute.value.path).toBe('/dashboard')
|
||||
expect(router.currentRoute.value.name).toBe('home')
|
||||
})
|
||||
|
||||
it('allows authenticated users to navigate from onboarding/did to /dashboard', async () => {
|
||||
mockStore.isAuthenticated = true
|
||||
|
||||
await router.push('/onboarding/did')
|
||||
expect(router.currentRoute.value.path).toBe('/onboarding/did')
|
||||
|
||||
await router.push('/dashboard')
|
||||
expect(router.currentRoute.value.path).toBe('/dashboard')
|
||||
expect(router.currentRoute.value.name).toBe('home')
|
||||
})
|
||||
|
||||
it('allows navigation through the full onboarding sequence', async () => {
|
||||
mockStore.isAuthenticated = true
|
||||
|
||||
await router.push('/onboarding/intro')
|
||||
expect(router.currentRoute.value.name).toBe('onboarding-intro')
|
||||
|
||||
await router.push('/onboarding/path')
|
||||
expect(router.currentRoute.value.name).toBe('onboarding-path')
|
||||
|
||||
await router.push('/onboarding/did')
|
||||
expect(router.currentRoute.value.name).toBe('onboarding-did')
|
||||
|
||||
await router.push('/onboarding/identity')
|
||||
expect(router.currentRoute.value.name).toBe('onboarding-identity')
|
||||
|
||||
await router.push('/onboarding/backup')
|
||||
expect(router.currentRoute.value.name).toBe('onboarding-backup')
|
||||
|
||||
await router.push('/onboarding/verify')
|
||||
expect(router.currentRoute.value.name).toBe('onboarding-verify')
|
||||
|
||||
await router.push('/onboarding/done')
|
||||
expect(router.currentRoute.value.name).toBe('onboarding-done')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dashboard guard blocks unauthenticated access after onboarding', () => {
|
||||
it('prevents unauthenticated navigation from onboarding/done to /dashboard', async () => {
|
||||
// Start at onboarding done (public route, accessible without auth)
|
||||
await router.push('/onboarding/done')
|
||||
expect(router.currentRoute.value.path).toBe('/onboarding/done')
|
||||
|
||||
// Try to navigate to dashboard without auth
|
||||
mockStore.checkSession.mockResolvedValue(false)
|
||||
await router.push('/dashboard')
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
})
|
||||
|
||||
it('prevents unauthenticated access to /dashboard even with stale session', async () => {
|
||||
mockStore.isAuthenticated = false
|
||||
mockStore.needsSessionValidation.mockReturnValue(false)
|
||||
mockStore.checkSession.mockResolvedValue(false)
|
||||
|
||||
await router.push('/dashboard/apps')
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
})
|
||||
|
||||
it('allows dashboard access when session check succeeds', async () => {
|
||||
mockStore.isAuthenticated = false
|
||||
mockStore.checkSession.mockResolvedValue(true)
|
||||
|
||||
await router.push('/dashboard')
|
||||
expect(router.currentRoute.value.path).toBe('/dashboard')
|
||||
})
|
||||
})
|
||||
})
|
||||
93
neode-ui/src/router/__tests__/routes.test.ts
Normal file
93
neode-ui/src/router/__tests__/routes.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
/**
|
||||
* Tests for router route definitions and configuration.
|
||||
* Full guard tests are in guards.test.ts. This tests route structure.
|
||||
*/
|
||||
describe('router route definitions', () => {
|
||||
it('has expected public routes', () => {
|
||||
const publicPaths = ['/', '/login', '/onboarding/intro', '/onboarding/options',
|
||||
'/onboarding/path', '/onboarding/did', '/onboarding/identity',
|
||||
'/onboarding/backup', '/onboarding/verify', '/onboarding/done', '/recovery']
|
||||
|
||||
// These should all resolve (we test the path list itself)
|
||||
expect(publicPaths.length).toBe(11)
|
||||
publicPaths.forEach(p => {
|
||||
expect(typeof p).toBe('string')
|
||||
expect(p.startsWith('/')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('has expected dashboard routes', () => {
|
||||
const dashPaths = [
|
||||
'/dashboard', '/dashboard/apps', '/dashboard/marketplace',
|
||||
'/dashboard/cloud', '/dashboard/server', '/dashboard/web5',
|
||||
'/dashboard/settings', '/dashboard/chat', '/dashboard/monitoring',
|
||||
]
|
||||
|
||||
dashPaths.forEach(p => {
|
||||
expect(p.startsWith('/dashboard')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('has parameterized routes', () => {
|
||||
const paramRoutes = [
|
||||
{ path: '/dashboard/apps/:id', example: '/dashboard/apps/bitcoin-knots' },
|
||||
{ path: '/dashboard/marketplace/:id', example: '/dashboard/marketplace/lnd' },
|
||||
{ path: '/dashboard/cloud/:folderId', example: '/dashboard/cloud/photos' },
|
||||
{ path: '/dashboard/goals/:goalId', example: '/dashboard/goals/sync-bitcoin' },
|
||||
]
|
||||
|
||||
paramRoutes.forEach(r => {
|
||||
const pattern = r.path.replace(/:(\w+)/g, '([^/]+)')
|
||||
expect(new RegExp(`^${pattern}$`).test(r.example)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('containers routes redirect to apps', () => {
|
||||
// The router redirects /dashboard/containers -> /dashboard/apps
|
||||
// and /dashboard/containers/:id -> /dashboard/apps/:id
|
||||
const redirectMap = {
|
||||
'containers': 'apps',
|
||||
'containers/bitcoin-knots': 'apps/bitcoin-knots',
|
||||
}
|
||||
|
||||
Object.entries(redirectMap).forEach(([from, to]) => {
|
||||
expect(to).toBe(from.replace('containers', 'apps'))
|
||||
})
|
||||
})
|
||||
|
||||
it('SESSION_CHECK_TIMEOUT_MS is a reasonable value', () => {
|
||||
const SESSION_CHECK_TIMEOUT_MS = 8000
|
||||
expect(SESSION_CHECK_TIMEOUT_MS).toBeGreaterThan(1000)
|
||||
expect(SESSION_CHECK_TIMEOUT_MS).toBeLessThanOrEqual(15000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkSessionWithTimeout logic', () => {
|
||||
it('resolves with session result when fast', async () => {
|
||||
const checkSession = () => Promise.resolve(true)
|
||||
const result = await Promise.race([
|
||||
checkSession(),
|
||||
new Promise<boolean>((resolve) =>
|
||||
setTimeout(() => resolve(false), 8000)
|
||||
),
|
||||
])
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves false when session check fails', async () => {
|
||||
const checkSession = () => Promise.reject(new Error('Network error'))
|
||||
try {
|
||||
await Promise.race([
|
||||
checkSession(),
|
||||
new Promise<boolean>((resolve) =>
|
||||
setTimeout(() => resolve(false), 8000)
|
||||
),
|
||||
])
|
||||
} catch {
|
||||
// Expected - the catch in the real code returns false
|
||||
expect(true).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
304
neode-ui/src/router/index.ts
Normal file
304
neode-ui/src/router/index.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { nextTick } from 'vue'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { stopAllAudio } from '../composables/useLoginSounds'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('../views/OnboardingWrapper.vue'),
|
||||
meta: { public: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
component: () => import('../views/RootRedirect.vue'),
|
||||
},
|
||||
{
|
||||
path: 'login',
|
||||
name: 'login',
|
||||
component: () => import('../views/Login.vue'),
|
||||
},
|
||||
{
|
||||
path: 'onboarding/intro',
|
||||
name: 'onboarding-intro',
|
||||
component: () => import('../views/OnboardingIntro.vue'),
|
||||
},
|
||||
{
|
||||
path: 'onboarding/options',
|
||||
name: 'onboarding-options',
|
||||
component: () => import('../views/OnboardingOptions.vue'),
|
||||
},
|
||||
{
|
||||
path: 'onboarding/path',
|
||||
name: 'onboarding-path',
|
||||
component: () => import('../views/OnboardingPath.vue'),
|
||||
},
|
||||
{
|
||||
path: 'onboarding/did',
|
||||
name: 'onboarding-did',
|
||||
component: () => import('../views/OnboardingDid.vue'),
|
||||
},
|
||||
{
|
||||
path: 'onboarding/identity',
|
||||
name: 'onboarding-identity',
|
||||
component: () => import('../views/OnboardingIdentity.vue'),
|
||||
},
|
||||
{
|
||||
path: 'onboarding/backup',
|
||||
name: 'onboarding-backup',
|
||||
component: () => import('../views/OnboardingBackup.vue'),
|
||||
},
|
||||
{
|
||||
path: 'onboarding/verify',
|
||||
name: 'onboarding-verify',
|
||||
component: () => import('../views/OnboardingVerify.vue'),
|
||||
},
|
||||
{
|
||||
path: 'onboarding/done',
|
||||
name: 'onboarding-done',
|
||||
component: () => import('../views/OnboardingDone.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/recovery',
|
||||
name: 'recovery',
|
||||
component: () => import('../views/KioskRecovery.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: '/kiosk',
|
||||
name: 'kiosk',
|
||||
component: () => import('../views/Kiosk.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
component: () => import('../views/Dashboard.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'home',
|
||||
component: () => import('../views/Home.vue'),
|
||||
},
|
||||
{
|
||||
path: 'apps',
|
||||
name: 'apps',
|
||||
component: () => import('../views/Apps.vue'),
|
||||
},
|
||||
{
|
||||
path: 'apps/:id',
|
||||
name: 'app-details',
|
||||
component: () => import('../views/AppDetails.vue'),
|
||||
},
|
||||
{
|
||||
path: 'apps/lnd/channels',
|
||||
name: 'lightning-channels',
|
||||
component: () => import('../views/apps/LightningChannels.vue'),
|
||||
},
|
||||
{
|
||||
path: 'marketplace',
|
||||
name: 'marketplace',
|
||||
component: () => import('../views/Marketplace.vue'),
|
||||
},
|
||||
{
|
||||
path: 'marketplace/:id',
|
||||
name: 'marketplace-app-detail',
|
||||
component: () => import('../views/MarketplaceAppDetails.vue'),
|
||||
},
|
||||
{
|
||||
path: 'cloud',
|
||||
name: 'cloud',
|
||||
component: () => import('../views/Cloud.vue'),
|
||||
},
|
||||
{
|
||||
path: 'cloud/peers/:peerId?',
|
||||
name: 'peer-files',
|
||||
component: () => import('../views/PeerFiles.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: 'cloud/:folderId',
|
||||
name: 'cloud-folder',
|
||||
component: () => import('../views/CloudFolder.vue'),
|
||||
},
|
||||
{
|
||||
path: 'server',
|
||||
name: 'server',
|
||||
component: () => import('../views/Server.vue'),
|
||||
},
|
||||
{
|
||||
path: 'monitoring',
|
||||
name: 'monitoring',
|
||||
component: () => import('../views/Monitoring.vue'),
|
||||
},
|
||||
{
|
||||
path: 'server/federation',
|
||||
name: 'federation',
|
||||
component: () => import('../views/Federation.vue'),
|
||||
},
|
||||
{
|
||||
path: 'mesh',
|
||||
name: 'mesh',
|
||||
component: () => import('../views/Mesh.vue'),
|
||||
},
|
||||
{
|
||||
path: 'web5',
|
||||
name: 'web5',
|
||||
component: () => import('../views/Web5.vue'),
|
||||
},
|
||||
{
|
||||
path: 'web5/credentials',
|
||||
name: 'credentials',
|
||||
component: () => import('../views/Credentials.vue'),
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'settings',
|
||||
component: () => import('../views/Settings.vue'),
|
||||
},
|
||||
{
|
||||
path: 'settings/update',
|
||||
name: 'system-update',
|
||||
component: () => import('../views/SystemUpdate.vue'),
|
||||
},
|
||||
{
|
||||
path: 'goals/:goalId',
|
||||
name: 'goal-detail',
|
||||
component: () => import('../views/GoalDetail.vue'),
|
||||
},
|
||||
{
|
||||
path: 'chat',
|
||||
name: 'chat',
|
||||
component: () => import('../views/Chat.vue'),
|
||||
},
|
||||
{
|
||||
path: 'app-session/:appId',
|
||||
name: 'app-session',
|
||||
component: () => import('../views/AppSession.vue'),
|
||||
},
|
||||
// Containers removed: My Apps serves the same purpose. Redirect old links.
|
||||
{
|
||||
path: 'containers',
|
||||
redirect: () => ({ path: '/dashboard/apps' }),
|
||||
},
|
||||
{
|
||||
path: 'containers/:id',
|
||||
redirect: (to) => ({ path: `/dashboard/apps/${to.params.id}` }),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'not-found',
|
||||
component: () => import('../views/NotFound.vue'),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// Session check with timeout - avoids endless spinner on mobile/slow networks
|
||||
const SESSION_CHECK_TIMEOUT_MS = 8000
|
||||
|
||||
async function checkSessionWithTimeout(store: ReturnType<typeof useAppStore>): Promise<boolean> {
|
||||
try {
|
||||
return await Promise.race([
|
||||
store.checkSession(),
|
||||
new Promise<boolean>((resolve) =>
|
||||
setTimeout(() => resolve(false), SESSION_CHECK_TIMEOUT_MS)
|
||||
),
|
||||
])
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigation Guard
|
||||
* Handles authentication and onboarding flow routing
|
||||
*/
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
const store = useAppStore()
|
||||
const isPublic = to.meta.public
|
||||
|
||||
// Allow all public routes (login, onboarding) without auth check
|
||||
if (isPublic) {
|
||||
// If authenticated and visiting /login: show login immediately, validate in background.
|
||||
// This prevents endless spinner on mobile when checkSession hangs (slow/unreachable network).
|
||||
if (to.path === '/login' && store.isAuthenticated) {
|
||||
// Redirect back to intended page (from ?redirect= query) or default to home
|
||||
const redirectTo = (to.query.redirect as string) || '/dashboard'
|
||||
if (store.needsSessionValidation()) {
|
||||
next()
|
||||
checkSessionWithTimeout(store).then((valid) => {
|
||||
if (valid) {
|
||||
router.replace(redirectTo).catch(() => {})
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
next(redirectTo)
|
||||
return
|
||||
}
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
// Protected routes: validate session if stale auth from localStorage
|
||||
if (store.needsSessionValidation()) {
|
||||
// localStorage says we're authed — proceed immediately, revalidate in background.
|
||||
// No timeout wrapper here: a slow server shouldn't bounce the user to login.
|
||||
next()
|
||||
store.checkSession().then((valid) => {
|
||||
if (!valid) {
|
||||
router.replace({ path: '/login', query: { redirect: to.fullPath } }).catch(() => {})
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Not authenticated at all (with timeout to avoid endless spinner on mobile)
|
||||
if (!store.isAuthenticated) {
|
||||
const hasSession = await checkSessionWithTimeout(store)
|
||||
if (hasSession) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
next({ path: '/login', query: { redirect: to.fullPath } })
|
||||
return
|
||||
}
|
||||
|
||||
// Validated and authenticated - ensure WebSocket is connected
|
||||
if (!store.isConnected && !store.isReconnecting) {
|
||||
store.connectWebSocket().catch((err) => {
|
||||
if (import.meta.env.DEV) console.warn('[Router] WebSocket connection failed:', err)
|
||||
})
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
// Stop all login/splash audio when entering the dashboard
|
||||
router.afterEach((to, from) => {
|
||||
if (to.path.startsWith('/dashboard') && !from.path.startsWith('/dashboard')) {
|
||||
stopAllAudio()
|
||||
}
|
||||
})
|
||||
|
||||
// Focus Home nav item for gamepad when landing on dashboard home (e.g. after login)
|
||||
router.afterEach((to) => {
|
||||
if (to.path === '/dashboard' || to.path === '/dashboard/') {
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
const homeLink = document.querySelector<HTMLAnchorElement>(
|
||||
'[data-controller-zone="sidebar"] a[href="/dashboard"], [data-controller-zone="sidebar"] a[href="/dashboard/"]'
|
||||
)
|
||||
if (homeLink) homeLink.focus()
|
||||
}, 150)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
163
neode-ui/src/services/__tests__/contextBroker.test.ts
Normal file
163
neode-ui/src/services/__tests__/contextBroker.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { ref, type Ref } from 'vue'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/api/filebrowser-client', () => ({
|
||||
fileBrowserClient: {
|
||||
login: vi.fn(),
|
||||
isAuthenticated: false,
|
||||
getUsage: vi.fn(),
|
||||
listDirectory: vi.fn(),
|
||||
readFileAsText: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { ContextBroker } from '../contextBroker'
|
||||
import { useAIPermissionsStore } from '@/stores/aiPermissions'
|
||||
|
||||
describe('ContextBroker', () => {
|
||||
let broker: ContextBroker
|
||||
let iframeRef: Ref<HTMLIFrameElement | null>
|
||||
let mockPostMessage: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockPostMessage = vi.fn()
|
||||
iframeRef = ref<HTMLIFrameElement | null>({
|
||||
contentWindow: {
|
||||
postMessage: mockPostMessage,
|
||||
},
|
||||
} as unknown as HTMLIFrameElement)
|
||||
|
||||
broker = new ContextBroker(iframeRef, 'http://localhost:8100')
|
||||
})
|
||||
|
||||
it('creates with correct allowed origin', () => {
|
||||
expect(broker).toBeDefined()
|
||||
})
|
||||
|
||||
it('start registers message listener', () => {
|
||||
const addSpy = vi.spyOn(window, 'addEventListener')
|
||||
broker.start()
|
||||
expect(addSpy).toHaveBeenCalledWith('message', expect.any(Function))
|
||||
broker.stop()
|
||||
addSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('stop removes message listener', () => {
|
||||
const removeSpy = vi.spyOn(window, 'removeEventListener')
|
||||
broker.start()
|
||||
broker.stop()
|
||||
expect(removeSpy).toHaveBeenCalledWith('message', expect.any(Function))
|
||||
removeSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('sendTheme sends theme response to iframe', () => {
|
||||
broker.sendTheme()
|
||||
expect(mockPostMessage).toHaveBeenCalledWith(
|
||||
{ type: 'theme:response', theme: { accent: '#fb923c', mode: 'dark' } },
|
||||
expect.any(String),
|
||||
)
|
||||
})
|
||||
|
||||
it('sendPermissionsUpdate sends enabled categories to iframe', () => {
|
||||
const perms = useAIPermissionsStore()
|
||||
perms.toggle('apps')
|
||||
perms.toggle('system')
|
||||
|
||||
broker.sendPermissionsUpdate()
|
||||
expect(mockPostMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'permissions:update',
|
||||
categories: expect.arrayContaining(['apps', 'system']),
|
||||
}),
|
||||
expect.any(String),
|
||||
)
|
||||
})
|
||||
|
||||
it('does not post when iframe has no contentWindow', () => {
|
||||
iframeRef.value = null
|
||||
broker.sendTheme()
|
||||
expect(mockPostMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('path validation', () => {
|
||||
// Access private method via prototype
|
||||
const isPathAllowed = (path: string) => {
|
||||
return (broker as unknown as { isPathAllowed: (p: string) => boolean }).isPathAllowed(path)
|
||||
}
|
||||
|
||||
it('allows paths in /var/lib/archipelago/', () => {
|
||||
expect(isPathAllowed('/var/lib/archipelago/bitcoin/data.db')).toBe(true)
|
||||
})
|
||||
|
||||
it('allows paths in /var/log/', () => {
|
||||
expect(isPathAllowed('/var/log/syslog')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects paths outside allowed directories', () => {
|
||||
expect(isPathAllowed('/etc/shadow')).toBe(false)
|
||||
expect(isPathAllowed('/root/.ssh/id_rsa')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects paths with sensitive patterns', () => {
|
||||
expect(isPathAllowed('/var/lib/archipelago/secret.key')).toBe(false)
|
||||
expect(isPathAllowed('/var/lib/archipelago/password.txt')).toBe(false)
|
||||
expect(isPathAllowed('/var/lib/archipelago/wallet.dat')).toBe(false)
|
||||
expect(isPathAllowed('/var/lib/archipelago/.env')).toBe(false)
|
||||
expect(isPathAllowed('/var/lib/archipelago/admin.macaroon')).toBe(false)
|
||||
})
|
||||
|
||||
it('strips path traversal sequences before checking', () => {
|
||||
// After stripping ../, path becomes /var/lib/archipelago/etc/passwd (still in allowed dir)
|
||||
expect(isPathAllowed('/var/lib/archipelago/../../etc/passwd')).toBe(true)
|
||||
// Path outside allowed dir is rejected even without traversal
|
||||
expect(isPathAllowed('/etc/passwd')).toBe(false)
|
||||
// Sensitive pattern inside allowed dir is still blocked
|
||||
expect(isPathAllowed('/var/lib/archipelago/../../etc/password')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('log redaction', () => {
|
||||
const redact = (line: string) => {
|
||||
return (ContextBroker as unknown as { redactLogLine: (l: string) => string }).redactLogLine(line)
|
||||
}
|
||||
|
||||
it('redacts password= patterns', () => {
|
||||
const result = redact('rpcpassword=mysecretpassword123')
|
||||
expect(result).not.toContain('mysecretpassword123')
|
||||
expect(result).toContain('[REDACTED]')
|
||||
})
|
||||
|
||||
it('redacts token= patterns', () => {
|
||||
const result = redact('token=abc123def456')
|
||||
expect(result).not.toContain('abc123def456')
|
||||
})
|
||||
|
||||
it('redacts long hex strings (private keys)', () => {
|
||||
const hexKey = 'a'.repeat(64)
|
||||
const result = redact(`key: ${hexKey}`)
|
||||
expect(result).not.toContain(hexKey)
|
||||
expect(result).toContain('[REDACTED_KEY]')
|
||||
})
|
||||
|
||||
it('redacts long base64 strings (macaroons/tokens)', () => {
|
||||
const b64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/AABB'
|
||||
const result = redact(`macaroon: ${b64}`)
|
||||
expect(result).toContain('[REDACTED')
|
||||
})
|
||||
|
||||
it('preserves non-sensitive log lines', () => {
|
||||
const line = '2026-03-11 INFO: Bitcoin block height: 841234'
|
||||
expect(redact(line)).toBe(line)
|
||||
})
|
||||
})
|
||||
})
|
||||
624
neode-ui/src/services/contextBroker.ts
Normal file
624
neode-ui/src/services/contextBroker.ts
Normal file
@@ -0,0 +1,624 @@
|
||||
import type { Ref } from 'vue'
|
||||
import type {
|
||||
AIUIRequest,
|
||||
ArchyResponse,
|
||||
AIContextCategory,
|
||||
ArchyContextResponse,
|
||||
ArchyActionResponse,
|
||||
} from '@/types/aiui-protocol'
|
||||
import { useAIPermissionsStore } from '@/stores/aiPermissions'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useContainerStore, BUNDLED_APPS } from '@/stores/container'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { fileBrowserClient } from '@/api/filebrowser-client'
|
||||
|
||||
/**
|
||||
* Context Broker — mediates all communication between AIUI (iframe) and Archy.
|
||||
*
|
||||
* AIUI sends context/action requests via postMessage.
|
||||
* The broker checks permissions, fetches data from Pinia stores,
|
||||
* sanitizes it (strips sensitive fields), and responds.
|
||||
*/
|
||||
export class ContextBroker {
|
||||
private iframe: Ref<HTMLIFrameElement | null>
|
||||
private allowedOrigin: string
|
||||
private listener: ((e: MessageEvent) => void) | null = null
|
||||
|
||||
constructor(iframe: Ref<HTMLIFrameElement | null>, aiuiUrl: string) {
|
||||
this.iframe = iframe
|
||||
try {
|
||||
const url = new URL(aiuiUrl, window.location.origin)
|
||||
this.allowedOrigin = url.origin
|
||||
} catch {
|
||||
this.allowedOrigin = window.location.origin
|
||||
}
|
||||
}
|
||||
|
||||
start() {
|
||||
this.listener = (e: MessageEvent) => this.handleMessage(e)
|
||||
window.addEventListener('message', this.listener)
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.listener) {
|
||||
window.removeEventListener('message', this.listener)
|
||||
this.listener = null
|
||||
}
|
||||
}
|
||||
|
||||
sendPermissionsUpdate() {
|
||||
const perms = useAIPermissionsStore()
|
||||
this.postToIframe({
|
||||
type: 'permissions:update',
|
||||
categories: perms.enabledCategories,
|
||||
})
|
||||
}
|
||||
|
||||
sendTheme() {
|
||||
this.postToIframe({
|
||||
type: 'theme:response',
|
||||
theme: { accent: '#fb923c', mode: 'dark' },
|
||||
})
|
||||
}
|
||||
|
||||
private handleMessage(event: MessageEvent) {
|
||||
if (event.origin !== this.allowedOrigin) return
|
||||
|
||||
const msg = event.data as AIUIRequest
|
||||
if (!msg || typeof msg.type !== 'string') return
|
||||
|
||||
switch (msg.type) {
|
||||
case 'ready':
|
||||
this.sendPermissionsUpdate()
|
||||
this.sendTheme()
|
||||
break
|
||||
case 'context:request':
|
||||
this.handleContextRequest(msg.id, msg.category, msg.query)
|
||||
break
|
||||
case 'action:request':
|
||||
this.handleActionRequest(msg.id, msg.action, msg.params)
|
||||
break
|
||||
case 'theme:request':
|
||||
this.sendTheme()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private async handleContextRequest(id: string, category: AIContextCategory, query?: string) {
|
||||
const perms = useAIPermissionsStore()
|
||||
|
||||
if (!perms.isEnabled(category)) {
|
||||
this.postToIframe({
|
||||
type: 'context:response',
|
||||
id,
|
||||
data: null,
|
||||
permitted: false,
|
||||
} satisfies ArchyContextResponse)
|
||||
return
|
||||
}
|
||||
|
||||
const data = await this.fetchAndSanitize(category, query)
|
||||
this.postToIframe({
|
||||
type: 'context:response',
|
||||
id,
|
||||
data,
|
||||
permitted: true,
|
||||
} satisfies ArchyContextResponse)
|
||||
}
|
||||
|
||||
private handleActionRequest(id: string, action: string, params: Record<string, string>) {
|
||||
const appStore = useAppStore()
|
||||
let success = false
|
||||
let error: string | undefined
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case 'navigate':
|
||||
if (params.path) {
|
||||
window.dispatchEvent(new CustomEvent('aiui:navigate', { detail: params.path }))
|
||||
success = true
|
||||
} else {
|
||||
error = 'Missing path parameter'
|
||||
}
|
||||
break
|
||||
|
||||
case 'open-app':
|
||||
case 'launch-app':
|
||||
if (params.appId) {
|
||||
const url = this.getAppUrl(params.appId)
|
||||
if (url) {
|
||||
window.dispatchEvent(new CustomEvent('aiui:open-app', { detail: params.appId }))
|
||||
success = true
|
||||
} else {
|
||||
error = `App "${params.appId}" not found or not running`
|
||||
}
|
||||
} else {
|
||||
error = 'Missing appId parameter'
|
||||
}
|
||||
break
|
||||
|
||||
case 'install-app':
|
||||
if (params.appId && params.marketplaceUrl && params.version) {
|
||||
const packages = appStore.packages || {}
|
||||
const existing = packages[params.appId]
|
||||
if (existing && existing.state === 'installed') {
|
||||
this.postToIframe({
|
||||
type: 'action:response',
|
||||
id,
|
||||
success: false,
|
||||
error: `${params.appId} is already installed`,
|
||||
} satisfies ArchyActionResponse)
|
||||
return
|
||||
}
|
||||
// Capture values for use in closure
|
||||
const appId = params.appId
|
||||
const marketplaceUrl = params.marketplaceUrl
|
||||
const version = params.version
|
||||
// Emit event for UI confirmation instead of installing directly
|
||||
window.dispatchEvent(new CustomEvent('aiui:install-request', {
|
||||
detail: { requestId: id, appId, marketplaceUrl, version },
|
||||
}))
|
||||
{
|
||||
const broker = this
|
||||
const responseHandler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail as { requestId: string; confirmed: boolean }
|
||||
if (detail.requestId !== id) return
|
||||
window.removeEventListener('aiui:install-response', responseHandler)
|
||||
if (detail.confirmed) {
|
||||
appStore.installPackage(appId, marketplaceUrl, version).then(() => {
|
||||
broker.postToIframe({
|
||||
type: 'action:response',
|
||||
id,
|
||||
success: true,
|
||||
} satisfies ArchyActionResponse)
|
||||
}).catch((err: Error) => {
|
||||
broker.postToIframe({
|
||||
type: 'action:response',
|
||||
id,
|
||||
success: false,
|
||||
error: err.message,
|
||||
} satisfies ArchyActionResponse)
|
||||
})
|
||||
} else {
|
||||
broker.postToIframe({
|
||||
type: 'action:response',
|
||||
id,
|
||||
success: false,
|
||||
error: 'User declined the installation',
|
||||
} satisfies ArchyActionResponse)
|
||||
}
|
||||
}
|
||||
window.addEventListener('aiui:install-response', responseHandler)
|
||||
setTimeout(() => {
|
||||
window.removeEventListener('aiui:install-response', responseHandler)
|
||||
}, 60000)
|
||||
}
|
||||
return
|
||||
}
|
||||
error = 'Missing required parameters (appId, marketplaceUrl, version)'
|
||||
break
|
||||
|
||||
case 'search-web':
|
||||
if (params.query) {
|
||||
this.handleSearchAction(id, params.query)
|
||||
return
|
||||
}
|
||||
error = 'Missing query parameter'
|
||||
break
|
||||
|
||||
case 'read-file':
|
||||
this.handleReadFileAction(id, params.path)
|
||||
return
|
||||
|
||||
case 'tail-logs':
|
||||
this.handleTailLogsAction(id, params.appId, params.lines)
|
||||
return
|
||||
|
||||
default:
|
||||
error = `Unknown action: ${action}`
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Unknown error'
|
||||
}
|
||||
|
||||
this.postToIframe({
|
||||
type: 'action:response',
|
||||
id,
|
||||
success,
|
||||
error,
|
||||
} satisfies ArchyActionResponse)
|
||||
}
|
||||
|
||||
private async handleSearchAction(id: string, query: string) {
|
||||
const appStore = useAppStore()
|
||||
const packages = appStore.packages || {}
|
||||
const searxng = packages['searxng']
|
||||
|
||||
if (!searxng || searxng.state !== 'installed' || searxng.installed?.status !== 'running') {
|
||||
this.postToIframe({
|
||||
type: 'action:response',
|
||||
id,
|
||||
success: false,
|
||||
error: 'SearXNG is not installed or not running',
|
||||
} satisfies ArchyActionResponse)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/apps/searxng/search?q=${encodeURIComponent(query)}&format=json`)
|
||||
const results: unknown = await response.json()
|
||||
this.postToIframe({
|
||||
type: 'action:response',
|
||||
id,
|
||||
success: true,
|
||||
data: results,
|
||||
} as ArchyActionResponse & { data: unknown })
|
||||
} catch (err) {
|
||||
this.postToIframe({
|
||||
type: 'action:response',
|
||||
id,
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : 'Search failed',
|
||||
} satisfies ArchyActionResponse)
|
||||
}
|
||||
}
|
||||
|
||||
private getAppUrl(appId: string): string | null {
|
||||
const appStore = useAppStore()
|
||||
const packages = appStore.packages || {}
|
||||
const pkg = packages[appId]
|
||||
if (pkg?.installed?.status === 'running') {
|
||||
const ifaces = pkg.installed['interface-addresses']
|
||||
if (ifaces) {
|
||||
const main = ifaces['main'] || Object.values(ifaces)[0]
|
||||
if (main?.['lan-address']) return main['lan-address']
|
||||
}
|
||||
}
|
||||
const containerStore = useContainerStore()
|
||||
const containers = containerStore.containers
|
||||
const container = containers.find(c => c.name === appId || c.name === `archy-${appId}`)
|
||||
if (container?.lan_address) return container.lan_address
|
||||
const bundled = BUNDLED_APPS.find(a => a.id === appId)
|
||||
if (bundled?.ports?.[0]) return `/apps/${appId}/`
|
||||
return null
|
||||
}
|
||||
|
||||
private async fetchAndSanitize(category: AIContextCategory, _query?: string): Promise<unknown> {
|
||||
const appStore = useAppStore()
|
||||
|
||||
switch (category) {
|
||||
case 'apps': return this.sanitizeApps(appStore)
|
||||
case 'system': return await this.sanitizeSystem(appStore)
|
||||
case 'network': return this.sanitizeNetwork(appStore)
|
||||
case 'wallet': return this.sanitizeWallet(appStore)
|
||||
case 'files': return this.sanitizeFiles()
|
||||
case 'bitcoin': return this.sanitizeBitcoin(appStore)
|
||||
case 'media': return this.sanitizeMedia(appStore)
|
||||
case 'search': return this.sanitizeSearch(appStore)
|
||||
case 'ai-local': return this.sanitizeAILocal(appStore)
|
||||
case 'notes': return this.sanitizeNotes()
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
// T4: Enhanced apps with version, health, URL, web UI info
|
||||
private sanitizeApps(store: ReturnType<typeof useAppStore>): unknown {
|
||||
const packages = store.packages || {}
|
||||
const containerStore = useContainerStore()
|
||||
|
||||
const apps = Object.entries(packages).map(([id, pkg]) => {
|
||||
const hasWebUI = !!pkg.manifest?.interfaces?.main?.ui
|
||||
const url = hasWebUI ? `/apps/${id}/` : null
|
||||
return {
|
||||
id,
|
||||
name: pkg.manifest?.title || id,
|
||||
version: pkg.manifest?.version || 'unknown',
|
||||
state: pkg.state || 'unknown',
|
||||
status: pkg.installed?.status || 'unknown',
|
||||
hasWebUI,
|
||||
url,
|
||||
}
|
||||
})
|
||||
|
||||
const bundledApps = containerStore.containers.map(c => ({
|
||||
id: c.name,
|
||||
name: BUNDLED_APPS.find(b => b.id === c.name)?.name || c.name,
|
||||
state: c.state === 'running' ? 'installed' : 'stopped',
|
||||
status: c.state,
|
||||
hasWebUI: !!(BUNDLED_APPS.find(b => b.id === c.name)?.ports?.length),
|
||||
url: c.lan_address || null,
|
||||
}))
|
||||
|
||||
return [...apps, ...bundledApps]
|
||||
}
|
||||
|
||||
// T5: Real system metrics from RPC
|
||||
private async sanitizeSystem(store: ReturnType<typeof useAppStore>): Promise<unknown> {
|
||||
const info = store.serverInfo
|
||||
const base = {
|
||||
version: info?.version || 'unknown',
|
||||
name: info?.name || 'Archipelago',
|
||||
}
|
||||
|
||||
try {
|
||||
const [metrics, time] = await Promise.all([
|
||||
rpcClient.call<{ cpu: number; disk: { used: number; total: number }; memory: { used: number; total: number } }>({ method: 'server.metrics' }),
|
||||
rpcClient.call<{ now: string; uptime: number }>({ method: 'server.time' }),
|
||||
])
|
||||
return {
|
||||
...base,
|
||||
cpu: metrics.cpu,
|
||||
memory: { used: metrics.memory.used, total: metrics.memory.total },
|
||||
disk: { used: metrics.disk.used, total: metrics.disk.total },
|
||||
uptime: time.uptime,
|
||||
}
|
||||
} catch {
|
||||
return { ...base, status: 'metrics unavailable' }
|
||||
}
|
||||
}
|
||||
|
||||
// T6: Network with peer count and Tor/Tailscale status
|
||||
private sanitizeNetwork(store: ReturnType<typeof useAppStore>): unknown {
|
||||
const info = store.serverInfo
|
||||
const containerStore = useContainerStore()
|
||||
const tailscale = containerStore.containers.find(c => c.name === 'tailscale')
|
||||
const hasTor = !!info?.['tor-address']
|
||||
|
||||
return {
|
||||
connected: store.isConnected,
|
||||
torConnected: hasTor,
|
||||
tailscaleActive: tailscale?.state === 'running',
|
||||
}
|
||||
}
|
||||
|
||||
// T7: Bitcoin status + deep data from backend RPC
|
||||
private async sanitizeBitcoin(store: ReturnType<typeof useAppStore>): Promise<unknown> {
|
||||
const packages = store.packages || {}
|
||||
const containerStore = useContainerStore()
|
||||
|
||||
const btcPkg = packages['bitcoind'] || packages['bitcoin-core'] || packages['bitcoin']
|
||||
const btcContainer = containerStore.containers.find(c =>
|
||||
c.name === 'bitcoin-knots' || c.name === 'archy-bitcoin-knots'
|
||||
)
|
||||
|
||||
const isRunning = (btcPkg?.installed?.status === 'running') ||
|
||||
(btcContainer?.state === 'running')
|
||||
|
||||
if (!isRunning) {
|
||||
return { available: false, message: 'Bitcoin Core not running' }
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await rpcClient.call<{
|
||||
block_height: number
|
||||
sync_progress: number
|
||||
chain: string
|
||||
difficulty: number
|
||||
mempool_size: number
|
||||
mempool_tx_count: number
|
||||
verification_progress: number
|
||||
}>({ method: 'bitcoin.getinfo' })
|
||||
return { available: true, status: 'running', ...info }
|
||||
} catch {
|
||||
return { available: true, status: 'running', network: 'mainnet' }
|
||||
}
|
||||
}
|
||||
|
||||
// T8: Media libraries from installed media apps
|
||||
private sanitizeMedia(store: ReturnType<typeof useAppStore>): unknown {
|
||||
const packages = store.packages || {}
|
||||
const mediaAppIds = ['plex', 'jellyfin', 'navidrome', 'nextcloud']
|
||||
const libraries: { source: string; name: string; status: string }[] = []
|
||||
|
||||
for (const id of mediaAppIds) {
|
||||
const pkg = packages[id]
|
||||
if (pkg && (pkg.state === 'installed' || pkg.state === 'running' || pkg.state === 'stopped')) {
|
||||
libraries.push({
|
||||
source: id,
|
||||
name: pkg.manifest?.title || id,
|
||||
status: pkg.state,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (libraries.length === 0) {
|
||||
return {
|
||||
available: false,
|
||||
libraries: [],
|
||||
message: 'No media apps installed. Install Plex or Jellyfin from the App Store.',
|
||||
}
|
||||
}
|
||||
return { available: true, libraries }
|
||||
}
|
||||
|
||||
// T9: Files from FileBrowser
|
||||
private async sanitizeFiles(): Promise<unknown> {
|
||||
try {
|
||||
if (!fileBrowserClient.isAuthenticated) {
|
||||
const ok = await fileBrowserClient.login()
|
||||
if (!ok) return { available: false, message: 'File browser authentication failed' }
|
||||
}
|
||||
const usage = await fileBrowserClient.getUsage()
|
||||
const items = await fileBrowserClient.listDirectory('/')
|
||||
const folders = items.filter(i => i.isDir).map(i => ({ name: i.name, path: i.path }))
|
||||
const recentFiles = items
|
||||
.filter(i => !i.isDir)
|
||||
.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime())
|
||||
.slice(0, 10)
|
||||
.map(i => ({ name: i.name, path: i.path, size: i.size, modified: i.modified }))
|
||||
return {
|
||||
available: true,
|
||||
totalSize: usage.totalSize,
|
||||
folderCount: usage.folderCount,
|
||||
fileCount: usage.fileCount,
|
||||
folders,
|
||||
recentFiles,
|
||||
}
|
||||
} catch {
|
||||
return { available: false, message: 'File browser not reachable' }
|
||||
}
|
||||
}
|
||||
|
||||
// T10: SearXNG search engine availability
|
||||
private sanitizeSearch(store: ReturnType<typeof useAppStore>): unknown {
|
||||
const packages = store.packages || {}
|
||||
const searxng = packages['searxng']
|
||||
if (!searxng || searxng.state !== 'installed' || searxng.installed?.status !== 'running') {
|
||||
return { available: false }
|
||||
}
|
||||
return { available: true, engine: 'searxng', endpoint: '/apps/searxng/' }
|
||||
}
|
||||
|
||||
// T11: Ollama local AI models
|
||||
private sanitizeAILocal(store: ReturnType<typeof useAppStore>): unknown {
|
||||
const packages = store.packages || {}
|
||||
const ollama = packages['ollama']
|
||||
if (!ollama || ollama.state !== 'installed' || ollama.installed?.status !== 'running') {
|
||||
return { available: false }
|
||||
}
|
||||
return {
|
||||
available: true,
|
||||
models: [],
|
||||
message: 'Ollama is running. Query /api/tags for model list.',
|
||||
}
|
||||
}
|
||||
|
||||
// T12: Wallet — LND deep data from backend RPC
|
||||
private async sanitizeWallet(store: ReturnType<typeof useAppStore>): Promise<unknown> {
|
||||
const packages = store.packages || {}
|
||||
const containerStore = useContainerStore()
|
||||
|
||||
const lndPkg = packages['lnd']
|
||||
const lndContainer = containerStore.containers.find(c =>
|
||||
c.name === 'lnd' || c.name === 'archy-lnd'
|
||||
)
|
||||
|
||||
const isRunning = (lndPkg?.installed?.status === 'running') ||
|
||||
(lndContainer?.state === 'running')
|
||||
|
||||
if (!isRunning) {
|
||||
return { available: false, message: 'Lightning (LND) not running' }
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await rpcClient.call<{
|
||||
alias: string
|
||||
num_active_channels: number
|
||||
num_peers: number
|
||||
synced_to_chain: boolean
|
||||
block_height: number
|
||||
balance_sats: number
|
||||
channel_balance_sats: number
|
||||
pending_open_balance: number
|
||||
}>({ method: 'lnd.getinfo' })
|
||||
return { available: true, status: 'running', ...info }
|
||||
} catch {
|
||||
return { available: true, status: 'running', message: 'LND is running but detailed info unavailable' }
|
||||
}
|
||||
}
|
||||
|
||||
// T13: Notes/documents
|
||||
private sanitizeNotes(): unknown {
|
||||
return {
|
||||
available: false,
|
||||
documents: [],
|
||||
message: 'No note-taking apps installed',
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly ALLOWED_FILE_DIRS = [
|
||||
'/var/lib/archipelago/',
|
||||
'/var/log/',
|
||||
'/opt/archipelago/',
|
||||
'/home/archipelago/',
|
||||
]
|
||||
|
||||
private static readonly SENSITIVE_PATH_PATTERNS = [
|
||||
'id_rsa', 'id_ed25519', 'private', 'secret', 'password',
|
||||
'seed', '.env', 'wallet', 'macaroon', 'tls.key', 'tls.cert',
|
||||
'credentials', 'keystore', 'mnemonic',
|
||||
]
|
||||
|
||||
private isPathAllowed(path: string): boolean {
|
||||
const normalized = path.replace(/\/+/g, '/').replace(/\.\.\//g, '')
|
||||
const inAllowedDir = ContextBroker.ALLOWED_FILE_DIRS.some(dir => normalized.startsWith(dir))
|
||||
if (!inAllowedDir) return false
|
||||
const lower = normalized.toLowerCase()
|
||||
return !ContextBroker.SENSITIVE_PATH_PATTERNS.some(pattern => lower.includes(pattern))
|
||||
}
|
||||
|
||||
private async handleReadFileAction(id: string, path?: string) {
|
||||
const perms = useAIPermissionsStore()
|
||||
if (!perms.isEnabled('files')) {
|
||||
this.postToIframe({ type: 'action:response', id, success: false, error: 'File access not permitted' } satisfies ArchyActionResponse)
|
||||
return
|
||||
}
|
||||
if (!path) {
|
||||
this.postToIframe({ type: 'action:response', id, success: false, error: 'Missing path parameter' } satisfies ArchyActionResponse)
|
||||
return
|
||||
}
|
||||
if (!this.isPathAllowed(path)) {
|
||||
this.postToIframe({ type: 'action:response', id, success: false, error: 'Access denied: path is outside allowed directories or contains sensitive patterns' } satisfies ArchyActionResponse)
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (!fileBrowserClient.isAuthenticated) {
|
||||
const ok = await fileBrowserClient.login()
|
||||
if (!ok) throw new Error('FileBrowser authentication failed')
|
||||
}
|
||||
const result = await fileBrowserClient.readFileAsText(path)
|
||||
this.postToIframe({
|
||||
type: 'action:response', id, success: true,
|
||||
data: { content: result.content, truncated: result.truncated, size: result.size, path },
|
||||
} as ArchyActionResponse)
|
||||
} catch (err) {
|
||||
this.postToIframe({
|
||||
type: 'action:response', id, success: false,
|
||||
error: err instanceof Error ? err.message : 'Failed to read file',
|
||||
} satisfies ArchyActionResponse)
|
||||
}
|
||||
}
|
||||
|
||||
private async handleTailLogsAction(id: string, appId?: string, linesStr?: string) {
|
||||
const perms = useAIPermissionsStore()
|
||||
if (!perms.isEnabled('apps')) {
|
||||
this.postToIframe({ type: 'action:response', id, success: false, error: 'App access not permitted' } satisfies ArchyActionResponse)
|
||||
return
|
||||
}
|
||||
if (!appId) {
|
||||
this.postToIframe({ type: 'action:response', id, success: false, error: 'Missing appId parameter' } satisfies ArchyActionResponse)
|
||||
return
|
||||
}
|
||||
const lines = Math.min(parseInt(linesStr || '50', 10) || 50, 200)
|
||||
try {
|
||||
const logs = await rpcClient.call<string[]>({ method: 'container-logs', params: { app_id: appId, lines } })
|
||||
const redactedLogs = logs.map(line => ContextBroker.redactLogLine(line))
|
||||
this.postToIframe({
|
||||
type: 'action:response', id, success: true,
|
||||
data: { appId, lines: redactedLogs, count: redactedLogs.length },
|
||||
} as ArchyActionResponse)
|
||||
} catch (err) {
|
||||
this.postToIframe({
|
||||
type: 'action:response', id, success: false,
|
||||
error: err instanceof Error ? err.message : 'Failed to fetch logs',
|
||||
} satisfies ArchyActionResponse)
|
||||
}
|
||||
}
|
||||
|
||||
private static redactLogLine(line: string): string {
|
||||
// Redact RPC passwords (e.g., rpcpassword=xxx)
|
||||
let redacted = line.replace(/(?:rpcpassword|rpcauth|password|passwd|secret|token|apikey|api_key|macaroon)[\s]*[=:]\s*\S+/gi, '$&'.replace(/[=:]\s*\S+/, '=[REDACTED]'))
|
||||
// More targeted: key=value patterns
|
||||
redacted = redacted.replace(/((?:password|secret|token|apikey|api_key|macaroon|rpcpassword|rpcauth)\s*[=:]\s*)\S+/gi, '$1[REDACTED]')
|
||||
// Redact long hex strings (>32 chars, likely private keys)
|
||||
redacted = redacted.replace(/\b[0-9a-fA-F]{64,}\b/g, '[REDACTED_KEY]')
|
||||
// Redact base64 macaroon values (long base64 strings)
|
||||
redacted = redacted.replace(/\b[A-Za-z0-9+/]{64,}={0,2}\b/g, '[REDACTED_TOKEN]')
|
||||
return redacted
|
||||
}
|
||||
|
||||
private postToIframe(msg: ArchyResponse) {
|
||||
if (!this.iframe.value?.contentWindow) return
|
||||
this.iframe.value.contentWindow.postMessage(msg, this.allowedOrigin)
|
||||
}
|
||||
}
|
||||
106
neode-ui/src/stores/__tests__/aiPermissions.test.ts
Normal file
106
neode-ui/src/stores/__tests__/aiPermissions.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useAIPermissionsStore, AI_PERMISSION_CATEGORIES } from '../aiPermissions'
|
||||
|
||||
const STORAGE_KEY = 'archipelago-ai-permissions'
|
||||
|
||||
describe('useAIPermissionsStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
localStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('starts with empty permissions when no localStorage', () => {
|
||||
const store = useAIPermissionsStore()
|
||||
expect(store.enabled.size).toBe(0)
|
||||
expect(store.noneEnabled).toBe(true)
|
||||
expect(store.allEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('loads valid categories from localStorage', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(['apps', 'system']))
|
||||
setActivePinia(createPinia())
|
||||
const store = useAIPermissionsStore()
|
||||
expect(store.isEnabled('apps')).toBe(true)
|
||||
expect(store.isEnabled('system')).toBe(true)
|
||||
expect(store.enabled.size).toBe(2)
|
||||
})
|
||||
|
||||
it('filters invalid categories from localStorage', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(['apps', 'invalid-category', 'system']))
|
||||
setActivePinia(createPinia())
|
||||
const store = useAIPermissionsStore()
|
||||
expect(store.enabled.size).toBe(2)
|
||||
expect(store.isEnabled('apps')).toBe(true)
|
||||
expect(store.isEnabled('system')).toBe(true)
|
||||
})
|
||||
|
||||
it('handles corrupt localStorage gracefully', () => {
|
||||
localStorage.setItem(STORAGE_KEY, 'not-valid-json{')
|
||||
setActivePinia(createPinia())
|
||||
const store = useAIPermissionsStore()
|
||||
expect(store.enabled.size).toBe(0)
|
||||
})
|
||||
|
||||
it('toggle adds a category', () => {
|
||||
const store = useAIPermissionsStore()
|
||||
store.toggle('bitcoin')
|
||||
expect(store.isEnabled('bitcoin')).toBe(true)
|
||||
})
|
||||
|
||||
it('toggle removes an enabled category', () => {
|
||||
const store = useAIPermissionsStore()
|
||||
store.toggle('bitcoin')
|
||||
store.toggle('bitcoin')
|
||||
expect(store.isEnabled('bitcoin')).toBe(false)
|
||||
})
|
||||
|
||||
it('toggle persists to localStorage', () => {
|
||||
const store = useAIPermissionsStore()
|
||||
store.toggle('apps')
|
||||
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]')
|
||||
expect(stored).toContain('apps')
|
||||
})
|
||||
|
||||
it('enableAll enables all categories', () => {
|
||||
const store = useAIPermissionsStore()
|
||||
store.enableAll()
|
||||
expect(store.allEnabled).toBe(true)
|
||||
expect(store.enabled.size).toBe(AI_PERMISSION_CATEGORIES.length)
|
||||
for (const cat of AI_PERMISSION_CATEGORIES) {
|
||||
expect(store.isEnabled(cat.id)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('disableAll disables all categories', () => {
|
||||
const store = useAIPermissionsStore()
|
||||
store.enableAll()
|
||||
store.disableAll()
|
||||
expect(store.noneEnabled).toBe(true)
|
||||
expect(store.enabled.size).toBe(0)
|
||||
})
|
||||
|
||||
it('enabledCategories returns array of enabled IDs', () => {
|
||||
const store = useAIPermissionsStore()
|
||||
store.toggle('apps')
|
||||
store.toggle('network')
|
||||
expect(store.enabledCategories).toContain('apps')
|
||||
expect(store.enabledCategories).toContain('network')
|
||||
expect(store.enabledCategories.length).toBe(2)
|
||||
})
|
||||
|
||||
it('AI_PERMISSION_CATEGORIES has 10 categories', () => {
|
||||
expect(AI_PERMISSION_CATEGORIES.length).toBe(10)
|
||||
})
|
||||
|
||||
it('all categories have required fields', () => {
|
||||
for (const cat of AI_PERMISSION_CATEGORIES) {
|
||||
expect(cat.id).toBeTruthy()
|
||||
expect(cat.label).toBeTruthy()
|
||||
expect(cat.description).toBeTruthy()
|
||||
expect(cat.icon).toBeTruthy()
|
||||
expect(cat.group).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
175
neode-ui/src/stores/__tests__/app.test.ts
Normal file
175
neode-ui/src/stores/__tests__/app.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
// Mock the rpc-client module
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
call: vi.fn(),
|
||||
installPackage: vi.fn(),
|
||||
uninstallPackage: vi.fn(),
|
||||
startPackage: vi.fn(),
|
||||
stopPackage: vi.fn(),
|
||||
restartPackage: vi.fn(),
|
||||
updateServer: vi.fn(),
|
||||
restartServer: vi.fn(),
|
||||
shutdownServer: vi.fn(),
|
||||
getMetrics: vi.fn(),
|
||||
getMarketplace: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock the websocket module
|
||||
vi.mock('@/api/websocket', () => ({
|
||||
wsClient: {
|
||||
connect: vi.fn().mockResolvedValue(undefined),
|
||||
disconnect: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
isConnected: vi.fn().mockReturnValue(false),
|
||||
onConnectionStateChange: vi.fn(),
|
||||
},
|
||||
applyDataPatch: vi.fn(),
|
||||
}))
|
||||
|
||||
import { useAppStore } from '../app'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { wsClient } from '@/api/websocket'
|
||||
|
||||
const mockedRpc = vi.mocked(rpcClient)
|
||||
const mockedWs = vi.mocked(wsClient)
|
||||
|
||||
describe('useAppStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
mockedWs.isConnected.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('starts with default unauthenticated state', () => {
|
||||
const store = useAppStore()
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
expect(store.isConnected).toBe(false)
|
||||
expect(store.isLoading).toBe(false)
|
||||
expect(store.error).toBeNull()
|
||||
expect(store.data).toBeNull()
|
||||
})
|
||||
|
||||
it('login succeeds and sets authenticated state', async () => {
|
||||
mockedRpc.login.mockResolvedValue(null)
|
||||
const store = useAppStore()
|
||||
|
||||
await store.login('password123')
|
||||
|
||||
expect(store.isAuthenticated).toBe(true)
|
||||
expect(localStorage.getItem('neode-auth')).toBe('true')
|
||||
expect(store.data).not.toBeNull()
|
||||
expect(store.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('login handles TOTP requirement', async () => {
|
||||
mockedRpc.login.mockResolvedValue({ requires_totp: true })
|
||||
const store = useAppStore()
|
||||
|
||||
const result = await store.login('password123')
|
||||
|
||||
expect(result).toEqual({ requires_totp: true })
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
})
|
||||
|
||||
it('login sets error on failure', async () => {
|
||||
mockedRpc.login.mockRejectedValue(new Error('Invalid password'))
|
||||
const store = useAppStore()
|
||||
|
||||
await expect(store.login('wrong')).rejects.toThrow('Invalid password')
|
||||
|
||||
expect(store.error).toBe('Invalid password')
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
expect(store.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('logout clears all state', async () => {
|
||||
mockedRpc.login.mockResolvedValue(null)
|
||||
mockedRpc.logout.mockResolvedValue(undefined)
|
||||
const store = useAppStore()
|
||||
|
||||
// Login first
|
||||
await store.login('password123')
|
||||
expect(store.isAuthenticated).toBe(true)
|
||||
|
||||
// Then logout
|
||||
await store.logout()
|
||||
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
expect(store.data).toBeNull()
|
||||
expect(store.isConnected).toBe(false)
|
||||
expect(localStorage.getItem('neode-auth')).toBeNull()
|
||||
expect(mockedWs.disconnect).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('logout still clears state even if RPC fails', async () => {
|
||||
mockedRpc.logout.mockRejectedValue(new Error('Network error'))
|
||||
localStorage.setItem('neode-auth', 'true')
|
||||
const store = useAppStore()
|
||||
|
||||
await store.logout()
|
||||
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
expect(localStorage.getItem('neode-auth')).toBeNull()
|
||||
})
|
||||
|
||||
it('checkSession returns true on valid session', async () => {
|
||||
localStorage.setItem('neode-auth', 'true')
|
||||
mockedRpc.call.mockResolvedValue('ping')
|
||||
const store = useAppStore()
|
||||
|
||||
const valid = await store.checkSession()
|
||||
|
||||
expect(valid).toBe(true)
|
||||
expect(store.isAuthenticated).toBe(true)
|
||||
expect(store.data).not.toBeNull()
|
||||
})
|
||||
|
||||
it('checkSession returns false when no auth in localStorage', async () => {
|
||||
const store = useAppStore()
|
||||
|
||||
const valid = await store.checkSession()
|
||||
|
||||
expect(valid).toBe(false)
|
||||
})
|
||||
|
||||
it('checkSession returns false and clears state on expired session', async () => {
|
||||
localStorage.setItem('neode-auth', 'true')
|
||||
mockedRpc.call.mockRejectedValue(new Error('401 Unauthorized'))
|
||||
const store = useAppStore()
|
||||
|
||||
const valid = await store.checkSession()
|
||||
|
||||
expect(valid).toBe(false)
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
expect(localStorage.getItem('neode-auth')).toBeNull()
|
||||
expect(mockedWs.disconnect).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('connectWebSocket subscribes and connects', async () => {
|
||||
mockedWs.connect.mockResolvedValue(undefined)
|
||||
// First call: not connected (triggers connect), second call: connected (after connect)
|
||||
mockedWs.isConnected.mockReturnValueOnce(false).mockReturnValue(true)
|
||||
const store = useAppStore()
|
||||
|
||||
await store.connectWebSocket()
|
||||
|
||||
expect(mockedWs.subscribe).toHaveBeenCalledOnce()
|
||||
expect(mockedWs.onConnectionStateChange).toHaveBeenCalledOnce()
|
||||
expect(mockedWs.connect).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('needsSessionValidation returns true when auth but not validated', () => {
|
||||
localStorage.setItem('neode-auth', 'true')
|
||||
const store = useAppStore()
|
||||
|
||||
// isAuthenticated is true from localStorage, but sessionValidated is false
|
||||
expect(store.needsSessionValidation()).toBe(true)
|
||||
})
|
||||
})
|
||||
164
neode-ui/src/stores/__tests__/appLauncher.test.ts
Normal file
164
neode-ui/src/stores/__tests__/appLauncher.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
// vi.hoisted runs before vi.mock hoisting
|
||||
const { mockPush, mockWindowOpen } = vi.hoisted(() => ({
|
||||
mockPush: vi.fn(),
|
||||
mockWindowOpen: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock vue-router
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}))
|
||||
vi.mock('@/router', () => ({
|
||||
default: { push: mockPush },
|
||||
}))
|
||||
|
||||
vi.stubGlobal('open', mockWindowOpen)
|
||||
|
||||
import { useAppLauncherStore } from '../appLauncher'
|
||||
|
||||
describe('useAppLauncherStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
// Default to HTTP to avoid proxy rewriting
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { origin: 'http://192.168.1.228', protocol: 'http:', hostname: '192.168.1.228' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('starts closed with empty state', () => {
|
||||
const store = useAppLauncherStore()
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(store.url).toBe('')
|
||||
expect(store.title).toBe('')
|
||||
})
|
||||
|
||||
it('routes known port apps to full-page session', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
// Port 8083 maps to /app/filebrowser/ — should route to session
|
||||
store.open({ url: 'http://192.168.1.228:8083', title: 'FileBrowser' })
|
||||
|
||||
// Legacy overlay should NOT open — routed to session view instead
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'filebrowser' } })
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes BTCPay (port 23000) to full-page session', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'http://192.168.1.228:23000', title: 'BTCPay' })
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'btcpay' } })
|
||||
})
|
||||
|
||||
it('routes Home Assistant (port 8123) to full-page session', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'http://192.168.1.228:8123', title: 'Home Assistant' })
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(mockPush).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes Grafana (port 3000) to full-page session', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'http://192.168.1.228:3000', title: 'Grafana' })
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(mockPush).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens in new tab when openInNewTab flag is set for unknown URL', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
// Use an unresolvable URL so it doesn't route to session
|
||||
store.open({ url: 'http://192.168.1.228:9999', title: 'Unknown', openInNewTab: true })
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
'http://192.168.1.228:9999',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
})
|
||||
|
||||
it('routes HTTPS same-host apps via session view', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { origin: 'https://192.168.1.228', protocol: 'https:', hostname: '192.168.1.228' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'http://192.168.1.228:8083', title: 'FileBrowser' })
|
||||
|
||||
// Known port — routes to full-page session
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'filebrowser' } })
|
||||
})
|
||||
|
||||
it('opens unknown URL in iframe overlay on HTTP', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
// Unresolvable URL — falls through to iframe overlay
|
||||
store.open({ url: 'http://192.168.1.228:9999', title: 'Custom App' })
|
||||
|
||||
expect(store.isOpen).toBe(true)
|
||||
expect(store.url).toBe('http://192.168.1.228:9999')
|
||||
expect(store.title).toBe('Custom App')
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens unknown different-host URL in iframe overlay', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { origin: 'https://192.168.1.228', protocol: 'https:', hostname: '192.168.1.228' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'http://192.168.1.100:9999', title: 'Remote App' })
|
||||
|
||||
// Different host, unknown port — opens in iframe overlay (no proxy rewrite)
|
||||
expect(store.isOpen).toBe(true)
|
||||
expect(store.url).toBe('http://192.168.1.100:9999')
|
||||
})
|
||||
|
||||
it('close resets state', () => {
|
||||
const store = useAppLauncherStore()
|
||||
// Use unknown URL to trigger iframe overlay
|
||||
store.open({ url: 'http://192.168.1.228:9999', title: 'Custom' })
|
||||
|
||||
store.close()
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(store.url).toBe('')
|
||||
expect(store.title).toBe('')
|
||||
})
|
||||
|
||||
it('close restores focus to previous element', async () => {
|
||||
vi.useFakeTimers()
|
||||
const store = useAppLauncherStore()
|
||||
const mockButton = { focus: vi.fn() } as unknown as HTMLElement
|
||||
Object.defineProperty(document, 'activeElement', { value: mockButton, configurable: true })
|
||||
|
||||
store.open({ url: 'http://192.168.1.228:9999', title: 'Custom' })
|
||||
store.close()
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(store.url).toBe('')
|
||||
|
||||
// requestAnimationFrame fires the focus restore callback
|
||||
vi.runAllTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
65
neode-ui/src/stores/__tests__/cli.test.ts
Normal file
65
neode-ui/src/stores/__tests__/cli.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
vi.mock('@/composables/useNavSounds', () => ({
|
||||
playNavSound: vi.fn(),
|
||||
}))
|
||||
|
||||
import { useCLIStore } from '../cli'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
|
||||
const mockedPlayNavSound = vi.mocked(playNavSound)
|
||||
|
||||
describe('useCLIStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('starts closed', () => {
|
||||
const store = useCLIStore()
|
||||
expect(store.isOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('open sets isOpen to true and plays sound', () => {
|
||||
const store = useCLIStore()
|
||||
store.open()
|
||||
expect(store.isOpen).toBe(true)
|
||||
expect(mockedPlayNavSound).toHaveBeenCalledWith('action')
|
||||
})
|
||||
|
||||
it('close sets isOpen to false without sound', () => {
|
||||
const store = useCLIStore()
|
||||
store.open()
|
||||
vi.clearAllMocks()
|
||||
store.close()
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(mockedPlayNavSound).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('toggle opens and plays sound when closed', () => {
|
||||
const store = useCLIStore()
|
||||
store.toggle()
|
||||
expect(store.isOpen).toBe(true)
|
||||
expect(mockedPlayNavSound).toHaveBeenCalledWith('action')
|
||||
})
|
||||
|
||||
it('toggle closes without sound when open', () => {
|
||||
const store = useCLIStore()
|
||||
store.open()
|
||||
vi.clearAllMocks()
|
||||
store.toggle()
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(mockedPlayNavSound).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('multiple toggles alternate state', () => {
|
||||
const store = useCLIStore()
|
||||
store.toggle()
|
||||
expect(store.isOpen).toBe(true)
|
||||
store.toggle()
|
||||
expect(store.isOpen).toBe(false)
|
||||
store.toggle()
|
||||
expect(store.isOpen).toBe(true)
|
||||
})
|
||||
})
|
||||
233
neode-ui/src/stores/__tests__/cloud.test.ts
Normal file
233
neode-ui/src/stores/__tests__/cloud.test.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
vi.mock('@/api/filebrowser-client', () => ({
|
||||
fileBrowserClient: {
|
||||
login: vi.fn(),
|
||||
listDirectory: vi.fn(),
|
||||
upload: vi.fn(),
|
||||
deleteItem: vi.fn(),
|
||||
downloadUrl: vi.fn(),
|
||||
createFolder: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { useCloudStore } from '../cloud'
|
||||
import { fileBrowserClient } from '@/api/filebrowser-client'
|
||||
|
||||
const mockedClient = vi.mocked(fileBrowserClient)
|
||||
|
||||
const mockItems = [
|
||||
{ name: 'photos', path: '/photos', size: 0, modified: '2026-01-01', isDir: true, type: '', extension: '' },
|
||||
{ name: 'readme.md', path: '/readme.md', size: 256, modified: '2026-01-02', isDir: false, type: '', extension: 'md' },
|
||||
{ name: 'archive.zip', path: '/archive.zip', size: 4096, modified: '2026-01-03', isDir: false, type: '', extension: 'zip' },
|
||||
]
|
||||
|
||||
describe('useCloudStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('starts with default state', () => {
|
||||
const store = useCloudStore()
|
||||
expect(store.currentPath).toBe('/')
|
||||
expect(store.items).toEqual([])
|
||||
expect(store.loading).toBe(false)
|
||||
expect(store.error).toBeNull()
|
||||
expect(store.authenticated).toBe(false)
|
||||
})
|
||||
|
||||
it('init authenticates with filebrowser', async () => {
|
||||
mockedClient.login.mockResolvedValue(true)
|
||||
const store = useCloudStore()
|
||||
|
||||
const result = await store.init()
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(store.authenticated).toBe(true)
|
||||
expect(mockedClient.login).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('init returns false on auth failure', async () => {
|
||||
mockedClient.login.mockResolvedValue(false)
|
||||
const store = useCloudStore()
|
||||
|
||||
const result = await store.init()
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(store.authenticated).toBe(false)
|
||||
})
|
||||
|
||||
it('init skips login if already authenticated', async () => {
|
||||
mockedClient.login.mockResolvedValue(true)
|
||||
const store = useCloudStore()
|
||||
|
||||
await store.init()
|
||||
await store.init()
|
||||
|
||||
expect(mockedClient.login).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('navigate loads items and updates path', async () => {
|
||||
mockedClient.login.mockResolvedValue(true)
|
||||
mockedClient.listDirectory.mockResolvedValue(mockItems)
|
||||
const store = useCloudStore()
|
||||
store.authenticated = true
|
||||
|
||||
await store.navigate('/photos')
|
||||
|
||||
expect(store.items).toEqual(mockItems)
|
||||
expect(store.currentPath).toBe('/photos')
|
||||
expect(store.loading).toBe(false)
|
||||
expect(store.error).toBeNull()
|
||||
})
|
||||
|
||||
it('navigate authenticates if not authenticated', async () => {
|
||||
mockedClient.login.mockResolvedValue(true)
|
||||
mockedClient.listDirectory.mockResolvedValue(mockItems)
|
||||
const store = useCloudStore()
|
||||
|
||||
await store.navigate('/')
|
||||
|
||||
expect(mockedClient.login).toHaveBeenCalled()
|
||||
expect(store.items).toEqual(mockItems)
|
||||
})
|
||||
|
||||
it('navigate sets error on auth failure', async () => {
|
||||
mockedClient.login.mockResolvedValue(false)
|
||||
const store = useCloudStore()
|
||||
|
||||
await store.navigate('/')
|
||||
|
||||
expect(store.error).toBe('Failed to authenticate with File Browser')
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('navigate falls back to creating directory on list failure', async () => {
|
||||
const store = useCloudStore()
|
||||
store.authenticated = true
|
||||
|
||||
// First listDirectory fails, then createFolder succeeds, then retry succeeds
|
||||
mockedClient.listDirectory
|
||||
.mockRejectedValueOnce(new Error('Not found'))
|
||||
.mockResolvedValueOnce([])
|
||||
mockedClient.createFolder.mockResolvedValue(undefined)
|
||||
|
||||
await store.navigate('/new-folder')
|
||||
|
||||
expect(mockedClient.createFolder).toHaveBeenCalledWith('/', 'new-folder')
|
||||
expect(store.currentPath).toBe('/new-folder')
|
||||
})
|
||||
|
||||
it('navigate falls back to root when directory creation also fails', async () => {
|
||||
const store = useCloudStore()
|
||||
store.authenticated = true
|
||||
|
||||
// Call 1: listDirectory('/deep/nested') rejects
|
||||
// Call 2: listDirectory('/') in the fallback catch resolves
|
||||
mockedClient.listDirectory
|
||||
.mockRejectedValueOnce(new Error('Not found'))
|
||||
.mockResolvedValueOnce(mockItems)
|
||||
|
||||
mockedClient.createFolder.mockRejectedValueOnce(new Error('Create failed'))
|
||||
|
||||
await store.navigate('/deep/nested')
|
||||
|
||||
expect(store.currentPath).toBe('/')
|
||||
expect(store.items).toEqual(mockItems)
|
||||
})
|
||||
|
||||
it('navigate sets error when root listing fails', async () => {
|
||||
const store = useCloudStore()
|
||||
store.authenticated = true
|
||||
|
||||
mockedClient.listDirectory.mockRejectedValueOnce(new Error('Server error'))
|
||||
|
||||
await store.navigate('/')
|
||||
|
||||
expect(store.error).toBe('Failed to list root directory')
|
||||
})
|
||||
|
||||
it('breadcrumbs computes from path', () => {
|
||||
const store = useCloudStore()
|
||||
store.currentPath = '/photos/vacation/2026'
|
||||
|
||||
expect(store.breadcrumbs).toEqual([
|
||||
{ name: 'Home', path: '/' },
|
||||
{ name: 'photos', path: '/photos' },
|
||||
{ name: 'vacation', path: '/photos/vacation' },
|
||||
{ name: '2026', path: '/photos/vacation/2026' },
|
||||
])
|
||||
})
|
||||
|
||||
it('breadcrumbs at root only shows Home', () => {
|
||||
const store = useCloudStore()
|
||||
expect(store.breadcrumbs).toEqual([{ name: 'Home', path: '/' }])
|
||||
})
|
||||
|
||||
it('sortedItems puts directories first, sorted alphabetically', () => {
|
||||
const store = useCloudStore()
|
||||
store.items = [
|
||||
{ name: 'readme.md', path: '/readme.md', size: 256, modified: '2026-01-01', isDir: false, type: '', extension: 'md' },
|
||||
{ name: 'docs', path: '/docs', size: 0, modified: '2026-01-01', isDir: true, type: '', extension: '' },
|
||||
{ name: 'archive.zip', path: '/archive.zip', size: 4096, modified: '2026-01-01', isDir: false, type: '', extension: 'zip' },
|
||||
{ name: 'assets', path: '/assets', size: 0, modified: '2026-01-01', isDir: true, type: '', extension: '' },
|
||||
]
|
||||
|
||||
const sorted = store.sortedItems
|
||||
expect(sorted.map((i) => i.name)).toEqual(['assets', 'docs', 'archive.zip', 'readme.md'])
|
||||
})
|
||||
|
||||
it('uploadFile uploads and refreshes', async () => {
|
||||
const store = useCloudStore()
|
||||
store.authenticated = true
|
||||
store.currentPath = '/uploads'
|
||||
mockedClient.upload.mockResolvedValue(undefined)
|
||||
mockedClient.listDirectory.mockResolvedValue([])
|
||||
|
||||
const file = new File(['test'], 'test.txt')
|
||||
await store.uploadFile(file)
|
||||
|
||||
expect(mockedClient.upload).toHaveBeenCalledWith('/uploads', file)
|
||||
expect(mockedClient.listDirectory).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deleteItem deletes and refreshes', async () => {
|
||||
const store = useCloudStore()
|
||||
store.authenticated = true
|
||||
store.currentPath = '/'
|
||||
mockedClient.deleteItem.mockResolvedValue(undefined)
|
||||
mockedClient.listDirectory.mockResolvedValue([])
|
||||
|
||||
await store.deleteItem('/old-file.txt')
|
||||
|
||||
expect(mockedClient.deleteItem).toHaveBeenCalledWith('/old-file.txt')
|
||||
expect(mockedClient.listDirectory).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('downloadUrl delegates to filebrowser client', () => {
|
||||
mockedClient.downloadUrl.mockReturnValue('http://localhost/api/raw/file.txt?auth=token')
|
||||
const store = useCloudStore()
|
||||
|
||||
const url = store.downloadUrl('/file.txt')
|
||||
|
||||
expect(url).toBe('http://localhost/api/raw/file.txt?auth=token')
|
||||
expect(mockedClient.downloadUrl).toHaveBeenCalledWith('/file.txt')
|
||||
})
|
||||
|
||||
it('reset clears all state', () => {
|
||||
const store = useCloudStore()
|
||||
store.currentPath = '/deep/path'
|
||||
store.items = mockItems
|
||||
store.loading = true
|
||||
store.error = 'something'
|
||||
|
||||
store.reset()
|
||||
|
||||
expect(store.currentPath).toBe('/')
|
||||
expect(store.items).toEqual([])
|
||||
expect(store.loading).toBe(false)
|
||||
expect(store.error).toBeNull()
|
||||
})
|
||||
})
|
||||
336
neode-ui/src/stores/__tests__/container.test.ts
Normal file
336
neode-ui/src/stores/__tests__/container.test.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
vi.mock('@/api/container-client', () => ({
|
||||
containerClient: {
|
||||
listContainers: vi.fn(),
|
||||
getHealthStatus: vi.fn(),
|
||||
installApp: vi.fn(),
|
||||
startContainer: vi.fn(),
|
||||
stopContainer: vi.fn(),
|
||||
removeContainer: vi.fn(),
|
||||
getContainerLogs: vi.fn(),
|
||||
getContainerStatus: vi.fn(),
|
||||
startBundledApp: vi.fn(),
|
||||
stopBundledApp: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { useContainerStore } from '../container'
|
||||
import { containerClient } from '@/api/container-client'
|
||||
|
||||
const mockedClient = vi.mocked(containerClient)
|
||||
|
||||
const mockContainers = [
|
||||
{ id: '1', name: 'bitcoin-knots', state: 'running' as const, status: 'Up 2 hours', image: 'bitcoinknots:29', created: '2026-01-01', ports: ['8332'], lan_address: 'http://localhost:8332' },
|
||||
{ id: '2', name: 'lnd', state: 'stopped' as const, status: 'Exited (0)', image: 'lnd:v0.18.4', created: '2026-01-01', ports: ['9735'], lan_address: undefined },
|
||||
{ id: '3', name: 'mempool', state: 'running' as const, status: 'Up 1 hour', image: 'mempool:latest', created: '2026-01-01', ports: ['8080'], lan_address: 'http://localhost:8080' },
|
||||
]
|
||||
|
||||
describe('useContainerStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('fetchContainers loads container list', async () => {
|
||||
mockedClient.listContainers.mockResolvedValue(mockContainers)
|
||||
const store = useContainerStore()
|
||||
|
||||
await store.fetchContainers()
|
||||
|
||||
expect(store.containers).toEqual(mockContainers)
|
||||
expect(store.loading).toBe(false)
|
||||
expect(store.error).toBeNull()
|
||||
})
|
||||
|
||||
it('fetchContainers sets error on failure', async () => {
|
||||
mockedClient.listContainers.mockRejectedValue(new Error('Connection refused'))
|
||||
const store = useContainerStore()
|
||||
|
||||
await store.fetchContainers()
|
||||
|
||||
expect(store.error).toBe('Connection refused')
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('runningContainers filters correctly', async () => {
|
||||
mockedClient.listContainers.mockResolvedValue(mockContainers)
|
||||
const store = useContainerStore()
|
||||
|
||||
await store.fetchContainers()
|
||||
|
||||
expect(store.runningContainers).toHaveLength(2)
|
||||
expect(store.runningContainers.map(c => c.name)).toEqual(['bitcoin-knots', 'mempool'])
|
||||
})
|
||||
|
||||
it('stoppedContainers filters correctly', async () => {
|
||||
mockedClient.listContainers.mockResolvedValue(mockContainers)
|
||||
const store = useContainerStore()
|
||||
|
||||
await store.fetchContainers()
|
||||
|
||||
expect(store.stoppedContainers).toHaveLength(1)
|
||||
expect(store.stoppedContainers[0]!.name).toBe('lnd')
|
||||
})
|
||||
|
||||
it('startContainer calls client and refreshes', async () => {
|
||||
mockedClient.startContainer.mockResolvedValue(undefined)
|
||||
mockedClient.listContainers.mockResolvedValue(mockContainers)
|
||||
mockedClient.getHealthStatus.mockResolvedValue({ 'bitcoin-knots': 'healthy' })
|
||||
const store = useContainerStore()
|
||||
|
||||
await store.startContainer('bitcoin-knots')
|
||||
|
||||
expect(mockedClient.startContainer).toHaveBeenCalledWith('bitcoin-knots')
|
||||
expect(mockedClient.listContainers).toHaveBeenCalled()
|
||||
expect(mockedClient.getHealthStatus).toHaveBeenCalled()
|
||||
expect(store.isAppLoading('bitcoin-knots')).toBe(false)
|
||||
})
|
||||
|
||||
it('stopContainer calls client and refreshes', async () => {
|
||||
mockedClient.stopContainer.mockResolvedValue(undefined)
|
||||
mockedClient.listContainers.mockResolvedValue(mockContainers)
|
||||
const store = useContainerStore()
|
||||
|
||||
await store.stopContainer('lnd')
|
||||
|
||||
expect(mockedClient.stopContainer).toHaveBeenCalledWith('lnd')
|
||||
expect(mockedClient.listContainers).toHaveBeenCalled()
|
||||
expect(store.isAppLoading('lnd')).toBe(false)
|
||||
})
|
||||
|
||||
it('getAppState returns correct states', async () => {
|
||||
mockedClient.listContainers.mockResolvedValue(mockContainers)
|
||||
const store = useContainerStore()
|
||||
|
||||
await store.fetchContainers()
|
||||
|
||||
expect(store.getAppState('bitcoin-knots')).toBe('running')
|
||||
expect(store.getAppState('lnd')).toBe('stopped')
|
||||
expect(store.getAppState('nonexistent')).toBe('not-installed')
|
||||
})
|
||||
|
||||
it('isAppLoading tracks per-app loading state', async () => {
|
||||
let resolveStart: (() => void) | undefined
|
||||
mockedClient.startContainer.mockImplementation(() => new Promise(r => { resolveStart = r }))
|
||||
mockedClient.listContainers.mockResolvedValue(mockContainers)
|
||||
mockedClient.getHealthStatus.mockResolvedValue({})
|
||||
const store = useContainerStore()
|
||||
|
||||
const startPromise = store.startContainer('bitcoin-knots')
|
||||
|
||||
expect(store.isAppLoading('bitcoin-knots')).toBe(true)
|
||||
expect(store.isAppLoading('lnd')).toBe(false)
|
||||
|
||||
resolveStart!()
|
||||
await startPromise
|
||||
|
||||
expect(store.isAppLoading('bitcoin-knots')).toBe(false)
|
||||
})
|
||||
|
||||
it('fetchHealthStatus loads health data', async () => {
|
||||
mockedClient.getHealthStatus.mockResolvedValue({ 'bitcoin-knots': 'healthy', 'lnd': 'degraded' })
|
||||
const store = useContainerStore()
|
||||
|
||||
await store.fetchHealthStatus()
|
||||
|
||||
expect(store.getHealthStatus('bitcoin-knots')).toBe('healthy')
|
||||
expect(store.getHealthStatus('lnd')).toBe('degraded')
|
||||
expect(store.getHealthStatus('unknown-app')).toBe('unknown')
|
||||
})
|
||||
|
||||
it('fetchHealthStatus handles errors silently', async () => {
|
||||
mockedClient.getHealthStatus.mockRejectedValue(new Error('fail'))
|
||||
const store = useContainerStore()
|
||||
|
||||
await store.fetchHealthStatus()
|
||||
// Should not throw, healthStatus stays empty
|
||||
expect(store.healthStatus).toEqual({})
|
||||
})
|
||||
|
||||
it('installApp installs and refreshes containers', async () => {
|
||||
mockedClient.installApp.mockResolvedValue('new-app')
|
||||
mockedClient.listContainers.mockResolvedValue(mockContainers)
|
||||
const store = useContainerStore()
|
||||
|
||||
const result = await store.installApp('/path/to/manifest')
|
||||
|
||||
expect(result).toBe('new-app')
|
||||
expect(mockedClient.installApp).toHaveBeenCalledWith('/path/to/manifest')
|
||||
expect(mockedClient.listContainers).toHaveBeenCalled()
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('installApp sets error and rethrows on failure', async () => {
|
||||
mockedClient.installApp.mockRejectedValue(new Error('Install failed'))
|
||||
const store = useContainerStore()
|
||||
|
||||
await expect(store.installApp('/bad/manifest')).rejects.toThrow('Install failed')
|
||||
expect(store.error).toBe('Install failed')
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('startContainer sets error on failure', async () => {
|
||||
mockedClient.startContainer.mockRejectedValue(new Error('Start failed'))
|
||||
const store = useContainerStore()
|
||||
|
||||
await expect(store.startContainer('bitcoin-knots')).rejects.toThrow('Start failed')
|
||||
expect(store.error).toBe('Start failed')
|
||||
expect(store.isAppLoading('bitcoin-knots')).toBe(false)
|
||||
})
|
||||
|
||||
it('stopContainer sets error on failure', async () => {
|
||||
mockedClient.stopContainer.mockRejectedValue(new Error('Stop failed'))
|
||||
const store = useContainerStore()
|
||||
|
||||
await expect(store.stopContainer('lnd')).rejects.toThrow('Stop failed')
|
||||
expect(store.error).toBe('Stop failed')
|
||||
expect(store.isAppLoading('lnd')).toBe(false)
|
||||
})
|
||||
|
||||
it('removeContainer removes and refreshes', async () => {
|
||||
mockedClient.removeContainer.mockResolvedValue(undefined)
|
||||
mockedClient.listContainers.mockResolvedValue([])
|
||||
const store = useContainerStore()
|
||||
|
||||
await store.removeContainer('old-app')
|
||||
|
||||
expect(mockedClient.removeContainer).toHaveBeenCalledWith('old-app')
|
||||
expect(mockedClient.listContainers).toHaveBeenCalled()
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('removeContainer sets error on failure', async () => {
|
||||
mockedClient.removeContainer.mockRejectedValue(new Error('Remove failed'))
|
||||
const store = useContainerStore()
|
||||
|
||||
await expect(store.removeContainer('old-app')).rejects.toThrow('Remove failed')
|
||||
expect(store.error).toBe('Remove failed')
|
||||
})
|
||||
|
||||
it('getContainerLogs returns logs', async () => {
|
||||
mockedClient.getContainerLogs.mockResolvedValue(['line1', 'line2', 'line3'])
|
||||
const store = useContainerStore()
|
||||
|
||||
const logs = await store.getContainerLogs('bitcoin-knots', 50)
|
||||
|
||||
expect(logs).toEqual(['line1', 'line2', 'line3'])
|
||||
expect(mockedClient.getContainerLogs).toHaveBeenCalledWith('bitcoin-knots', 50)
|
||||
})
|
||||
|
||||
it('getContainerLogs defaults to 100 lines', async () => {
|
||||
mockedClient.getContainerLogs.mockResolvedValue(['log output'])
|
||||
const store = useContainerStore()
|
||||
|
||||
await store.getContainerLogs('bitcoin-knots')
|
||||
|
||||
expect(mockedClient.getContainerLogs).toHaveBeenCalledWith('bitcoin-knots', 100)
|
||||
})
|
||||
|
||||
it('getContainerLogs sets error on failure', async () => {
|
||||
mockedClient.getContainerLogs.mockRejectedValue(new Error('Log error'))
|
||||
const store = useContainerStore()
|
||||
|
||||
await expect(store.getContainerLogs('bitcoin-knots')).rejects.toThrow('Log error')
|
||||
expect(store.error).toBe('Log error')
|
||||
})
|
||||
|
||||
it('getContainerStatus returns status', async () => {
|
||||
const status = { name: 'bitcoin-knots', state: 'running', uptime: '5h' }
|
||||
mockedClient.getContainerStatus.mockResolvedValue(status as never)
|
||||
const store = useContainerStore()
|
||||
|
||||
const result = await store.getContainerStatus('bitcoin-knots')
|
||||
|
||||
expect(result).toEqual(status)
|
||||
})
|
||||
|
||||
it('getContainerStatus sets error on failure', async () => {
|
||||
mockedClient.getContainerStatus.mockRejectedValue(new Error('Status error'))
|
||||
const store = useContainerStore()
|
||||
|
||||
await expect(store.getContainerStatus('bitcoin-knots')).rejects.toThrow('Status error')
|
||||
expect(store.error).toBe('Status error')
|
||||
})
|
||||
|
||||
it('startBundledApp starts and refreshes', async () => {
|
||||
mockedClient.startBundledApp.mockResolvedValue(undefined)
|
||||
mockedClient.listContainers.mockResolvedValue(mockContainers)
|
||||
mockedClient.getHealthStatus.mockResolvedValue({})
|
||||
const store = useContainerStore()
|
||||
|
||||
const app = { id: 'bitcoin-knots', name: 'Bitcoin Knots', image: 'btc:29', description: '', icon: '', ports: [], volumes: [], category: 'bitcoin' as const }
|
||||
await store.startBundledApp(app)
|
||||
|
||||
expect(mockedClient.startBundledApp).toHaveBeenCalledWith(app)
|
||||
expect(store.isAppLoading('bitcoin-knots')).toBe(false)
|
||||
})
|
||||
|
||||
it('startBundledApp sets error on failure', async () => {
|
||||
mockedClient.startBundledApp.mockRejectedValue(new Error('Start failed'))
|
||||
const store = useContainerStore()
|
||||
|
||||
const app = { id: 'test', name: 'Test', image: 'test:1', description: '', icon: '', ports: [], volumes: [], category: 'other' as const }
|
||||
await expect(store.startBundledApp(app)).rejects.toThrow('Start failed')
|
||||
expect(store.error).toBe('Start failed')
|
||||
expect(store.isAppLoading('test')).toBe(false)
|
||||
})
|
||||
|
||||
it('stopBundledApp stops and refreshes', async () => {
|
||||
mockedClient.stopBundledApp.mockResolvedValue(undefined)
|
||||
mockedClient.listContainers.mockResolvedValue([])
|
||||
const store = useContainerStore()
|
||||
|
||||
await store.stopBundledApp('bitcoin-knots')
|
||||
|
||||
expect(mockedClient.stopBundledApp).toHaveBeenCalledWith('bitcoin-knots')
|
||||
expect(store.isAppLoading('bitcoin-knots')).toBe(false)
|
||||
})
|
||||
|
||||
it('stopBundledApp sets error on failure', async () => {
|
||||
mockedClient.stopBundledApp.mockRejectedValue(new Error('Stop failed'))
|
||||
const store = useContainerStore()
|
||||
|
||||
await expect(store.stopBundledApp('bitcoin-knots')).rejects.toThrow('Stop failed')
|
||||
expect(store.error).toBe('Stop failed')
|
||||
expect(store.isAppLoading('bitcoin-knots')).toBe(false)
|
||||
})
|
||||
|
||||
it('getContainerById finds by name substring', async () => {
|
||||
mockedClient.listContainers.mockResolvedValue(mockContainers)
|
||||
const store = useContainerStore()
|
||||
await store.fetchContainers()
|
||||
|
||||
expect(store.getContainerById('bitcoin')?.name).toBe('bitcoin-knots')
|
||||
expect(store.getContainerById('nonexistent')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('getContainerForApp matches by exact name', async () => {
|
||||
mockedClient.listContainers.mockResolvedValue(mockContainers)
|
||||
const store = useContainerStore()
|
||||
await store.fetchContainers()
|
||||
|
||||
expect(store.getContainerForApp('bitcoin-knots')?.name).toBe('bitcoin-knots')
|
||||
expect(store.getContainerForApp('lnd')?.name).toBe('lnd')
|
||||
})
|
||||
|
||||
it('enrichedBundledApps includes lan_address from matching containers', async () => {
|
||||
mockedClient.listContainers.mockResolvedValue(mockContainers)
|
||||
const store = useContainerStore()
|
||||
await store.fetchContainers()
|
||||
|
||||
const enriched = store.enrichedBundledApps
|
||||
const btc = enriched.find(a => a.id === 'bitcoin-knots')
|
||||
expect(btc?.lan_address).toBe('http://localhost:8332')
|
||||
})
|
||||
|
||||
it('fetchContainers handles non-Error exceptions', async () => {
|
||||
mockedClient.listContainers.mockRejectedValue('string error')
|
||||
const store = useContainerStore()
|
||||
|
||||
await store.fetchContainers()
|
||||
|
||||
expect(store.error).toBe('Failed to fetch containers')
|
||||
})
|
||||
})
|
||||
52
neode-ui/src/stores/__tests__/controller.test.ts
Normal file
52
neode-ui/src/stores/__tests__/controller.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useControllerStore } from '../controller'
|
||||
|
||||
describe('useControllerStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('starts with default state', () => {
|
||||
const store = useControllerStore()
|
||||
expect(store.isActive).toBe(false)
|
||||
expect(store.gamepadCount).toBe(0)
|
||||
})
|
||||
|
||||
it('setActive sets isActive to true', () => {
|
||||
const store = useControllerStore()
|
||||
store.setActive(true)
|
||||
expect(store.isActive).toBe(true)
|
||||
})
|
||||
|
||||
it('setActive sets isActive to false', () => {
|
||||
const store = useControllerStore()
|
||||
store.setActive(true)
|
||||
store.setActive(false)
|
||||
expect(store.isActive).toBe(false)
|
||||
})
|
||||
|
||||
it('setGamepadCount updates count and activates when > 0', () => {
|
||||
const store = useControllerStore()
|
||||
store.setGamepadCount(2)
|
||||
expect(store.gamepadCount).toBe(2)
|
||||
expect(store.isActive).toBe(true)
|
||||
})
|
||||
|
||||
it('setGamepadCount deactivates when count is 0', () => {
|
||||
const store = useControllerStore()
|
||||
store.setGamepadCount(1)
|
||||
expect(store.isActive).toBe(true)
|
||||
store.setGamepadCount(0)
|
||||
expect(store.gamepadCount).toBe(0)
|
||||
expect(store.isActive).toBe(false)
|
||||
})
|
||||
|
||||
it('setActive does not affect gamepadCount', () => {
|
||||
const store = useControllerStore()
|
||||
store.setGamepadCount(3)
|
||||
store.setActive(false)
|
||||
expect(store.isActive).toBe(false)
|
||||
expect(store.gamepadCount).toBe(3)
|
||||
})
|
||||
})
|
||||
227
neode-ui/src/stores/__tests__/goals.test.ts
Normal file
227
neode-ui/src/stores/__tests__/goals.test.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
// Mock the app store
|
||||
const mockPackages: Record<string, { state: string }> = {}
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
packages: mockPackages,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock the goals data with a controlled set
|
||||
vi.mock('@/data/goals', () => ({
|
||||
GOALS: [
|
||||
{
|
||||
id: 'accept-payments',
|
||||
title: 'Accept Payments',
|
||||
subtitle: 'Receive Bitcoin and Lightning payments',
|
||||
icon: 'payments',
|
||||
category: 'payments',
|
||||
requiredApps: ['bitcoin-knots', 'lnd'],
|
||||
steps: [
|
||||
{ id: 'install-bitcoin', title: 'Install Bitcoin', description: '', appId: 'bitcoin-knots', action: 'install', isAutomatic: true },
|
||||
{ id: 'install-lnd', title: 'Install LND', description: '', appId: 'lnd', action: 'install', isAutomatic: true },
|
||||
{ id: 'open-channel', title: 'Open Channel', description: '', action: 'configure', isAutomatic: false },
|
||||
],
|
||||
estimatedTime: '~30 min',
|
||||
difficulty: 'beginner',
|
||||
},
|
||||
{
|
||||
id: 'create-identity',
|
||||
title: 'Create Identity',
|
||||
subtitle: 'Sovereign digital identity',
|
||||
icon: 'identity',
|
||||
category: 'identity',
|
||||
requiredApps: [],
|
||||
steps: [
|
||||
{ id: 'generate-did', title: 'Generate DID', description: '', action: 'verify', isAutomatic: true },
|
||||
{ id: 'setup-nostr', title: 'Setup Nostr', description: '', action: 'configure', isAutomatic: false },
|
||||
],
|
||||
estimatedTime: '~5 min',
|
||||
difficulty: 'beginner',
|
||||
},
|
||||
{
|
||||
id: 'store-photos',
|
||||
title: 'Store Photos',
|
||||
subtitle: 'Private photo backup',
|
||||
icon: 'photos',
|
||||
category: 'storage',
|
||||
requiredApps: ['immich'],
|
||||
steps: [
|
||||
{ id: 'install-immich', title: 'Install Immich', description: '', appId: 'immich', action: 'install', isAutomatic: true },
|
||||
{ id: 'configure-immich', title: 'Configure', description: '', action: 'configure', isAutomatic: false },
|
||||
],
|
||||
estimatedTime: '~15 min',
|
||||
difficulty: 'beginner',
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
import { useGoalStore } from '../goals'
|
||||
|
||||
describe('useGoalStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
// Clear mock packages
|
||||
Object.keys(mockPackages).forEach((k) => delete mockPackages[k])
|
||||
})
|
||||
|
||||
it('starts with empty progress', () => {
|
||||
const store = useGoalStore()
|
||||
expect(store.progress).toEqual({})
|
||||
})
|
||||
|
||||
it('loads progress from localStorage', () => {
|
||||
const savedProgress = {
|
||||
'accept-payments': {
|
||||
goalId: 'accept-payments',
|
||||
status: 'in-progress',
|
||||
currentStepIndex: 1,
|
||||
completedSteps: ['install-bitcoin'],
|
||||
startedAt: 1000,
|
||||
},
|
||||
}
|
||||
localStorage.setItem('archipelago-goal-progress', JSON.stringify(savedProgress))
|
||||
|
||||
const store = useGoalStore()
|
||||
expect(store.progress['accept-payments']).toBeDefined()
|
||||
expect(store.progress['accept-payments']!.completedSteps).toContain('install-bitcoin')
|
||||
})
|
||||
|
||||
it('handles corrupt localStorage data', () => {
|
||||
localStorage.setItem('archipelago-goal-progress', 'not-valid-json{{{')
|
||||
|
||||
const store = useGoalStore()
|
||||
expect(store.progress).toEqual({})
|
||||
})
|
||||
|
||||
it('startGoal creates progress entry and saves', () => {
|
||||
const store = useGoalStore()
|
||||
|
||||
store.startGoal('accept-payments')
|
||||
|
||||
expect(store.progress['accept-payments']).toBeDefined()
|
||||
expect(store.progress['accept-payments']!.status).toBe('in-progress')
|
||||
expect(store.progress['accept-payments']!.currentStepIndex).toBe(0)
|
||||
expect(store.progress['accept-payments']!.completedSteps).toEqual([])
|
||||
expect(localStorage.getItem('archipelago-goal-progress')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('completeStep adds step to completedSteps', () => {
|
||||
const store = useGoalStore()
|
||||
store.startGoal('accept-payments')
|
||||
|
||||
store.completeStep('accept-payments', 'install-bitcoin')
|
||||
|
||||
expect(store.progress['accept-payments']!.completedSteps).toContain('install-bitcoin')
|
||||
})
|
||||
|
||||
it('completeStep does not duplicate step IDs', () => {
|
||||
const store = useGoalStore()
|
||||
store.startGoal('accept-payments')
|
||||
|
||||
store.completeStep('accept-payments', 'install-bitcoin')
|
||||
store.completeStep('accept-payments', 'install-bitcoin')
|
||||
|
||||
expect(store.progress['accept-payments']!.completedSteps.filter((s) => s === 'install-bitcoin')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('completeStep marks goal completed when all steps done', () => {
|
||||
const store = useGoalStore()
|
||||
store.startGoal('accept-payments')
|
||||
|
||||
store.completeStep('accept-payments', 'install-bitcoin')
|
||||
store.completeStep('accept-payments', 'install-lnd')
|
||||
store.completeStep('accept-payments', 'open-channel')
|
||||
|
||||
expect(store.progress['accept-payments']!.status).toBe('completed')
|
||||
})
|
||||
|
||||
it('completeStep is a no-op when goal not started', () => {
|
||||
const store = useGoalStore()
|
||||
|
||||
store.completeStep('accept-payments', 'install-bitcoin')
|
||||
|
||||
expect(store.progress['accept-payments']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resetGoal removes progress entry', () => {
|
||||
const store = useGoalStore()
|
||||
store.startGoal('accept-payments')
|
||||
expect(store.progress['accept-payments']).toBeDefined()
|
||||
|
||||
store.resetGoal('accept-payments')
|
||||
|
||||
expect(store.progress['accept-payments']).toBeUndefined()
|
||||
})
|
||||
|
||||
describe('getGoalStatus', () => {
|
||||
it('returns not-started for unknown goal', () => {
|
||||
const store = useGoalStore()
|
||||
expect(store.getGoalStatus('nonexistent')).toBe('not-started')
|
||||
})
|
||||
|
||||
it('returns not-started when no apps installed and no progress', () => {
|
||||
const store = useGoalStore()
|
||||
expect(store.getGoalStatus('accept-payments')).toBe('not-started')
|
||||
})
|
||||
|
||||
it('returns completed when all required apps are running', () => {
|
||||
mockPackages['bitcoin-knots'] = { state: 'running' }
|
||||
mockPackages['lnd'] = { state: 'running' }
|
||||
|
||||
const store = useGoalStore()
|
||||
expect(store.getGoalStatus('accept-payments')).toBe('completed')
|
||||
})
|
||||
|
||||
it('returns in-progress when some required apps are installed', () => {
|
||||
mockPackages['bitcoin-knots'] = { state: 'running' }
|
||||
|
||||
const store = useGoalStore()
|
||||
expect(store.getGoalStatus('accept-payments')).toBe('in-progress')
|
||||
})
|
||||
|
||||
it('uses manual progress for goals without required apps', () => {
|
||||
const store = useGoalStore()
|
||||
|
||||
// create-identity has no required apps
|
||||
expect(store.getGoalStatus('create-identity')).toBe('not-started')
|
||||
|
||||
store.startGoal('create-identity')
|
||||
expect(store.getGoalStatus('create-identity')).toBe('in-progress')
|
||||
})
|
||||
|
||||
it('recognizes app aliases (immich-server matches immich)', () => {
|
||||
mockPackages['immich-server'] = { state: 'running' }
|
||||
|
||||
const store = useGoalStore()
|
||||
expect(store.getGoalStatus('store-photos')).toBe('completed')
|
||||
})
|
||||
|
||||
it('auto-syncs install steps from actual package state', () => {
|
||||
mockPackages['bitcoin-knots'] = { state: 'stopped' }
|
||||
|
||||
const store = useGoalStore()
|
||||
store.getGoalStatus('accept-payments')
|
||||
|
||||
// Should have auto-created progress and marked install-bitcoin as completed
|
||||
expect(store.progress['accept-payments']).toBeDefined()
|
||||
expect(store.progress['accept-payments']!.completedSteps).toContain('install-bitcoin')
|
||||
})
|
||||
})
|
||||
|
||||
it('goalStatuses computes status for all goals', () => {
|
||||
mockPackages['bitcoin-knots'] = { state: 'running' }
|
||||
mockPackages['lnd'] = { state: 'running' }
|
||||
|
||||
const store = useGoalStore()
|
||||
const statuses = store.goalStatuses
|
||||
|
||||
expect(statuses['accept-payments']).toBe('completed')
|
||||
expect(statuses['create-identity']).toBe('not-started')
|
||||
expect(statuses['store-photos']).toBe('not-started')
|
||||
})
|
||||
})
|
||||
52
neode-ui/src/stores/__tests__/loginTransition.test.ts
Normal file
52
neode-ui/src/stores/__tests__/loginTransition.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useLoginTransitionStore } from '../loginTransition'
|
||||
|
||||
describe('useLoginTransitionStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('starts with all flags false', () => {
|
||||
const store = useLoginTransitionStore()
|
||||
expect(store.justLoggedIn).toBe(false)
|
||||
expect(store.pendingWelcomeTyping).toBe(false)
|
||||
expect(store.startWelcomeTyping).toBe(false)
|
||||
})
|
||||
|
||||
it('setJustLoggedIn updates justLoggedIn', () => {
|
||||
const store = useLoginTransitionStore()
|
||||
store.setJustLoggedIn(true)
|
||||
expect(store.justLoggedIn).toBe(true)
|
||||
store.setJustLoggedIn(false)
|
||||
expect(store.justLoggedIn).toBe(false)
|
||||
})
|
||||
|
||||
it('setPendingWelcomeTyping updates pendingWelcomeTyping', () => {
|
||||
const store = useLoginTransitionStore()
|
||||
store.setPendingWelcomeTyping(true)
|
||||
expect(store.pendingWelcomeTyping).toBe(true)
|
||||
store.setPendingWelcomeTyping(false)
|
||||
expect(store.pendingWelcomeTyping).toBe(false)
|
||||
})
|
||||
|
||||
it('setStartWelcomeTyping updates startWelcomeTyping', () => {
|
||||
const store = useLoginTransitionStore()
|
||||
store.setStartWelcomeTyping(true)
|
||||
expect(store.startWelcomeTyping).toBe(true)
|
||||
store.setStartWelcomeTyping(false)
|
||||
expect(store.startWelcomeTyping).toBe(false)
|
||||
})
|
||||
|
||||
it('flags are independent of each other', () => {
|
||||
const store = useLoginTransitionStore()
|
||||
store.setJustLoggedIn(true)
|
||||
store.setPendingWelcomeTyping(true)
|
||||
expect(store.startWelcomeTyping).toBe(false)
|
||||
|
||||
store.setStartWelcomeTyping(true)
|
||||
store.setJustLoggedIn(false)
|
||||
expect(store.pendingWelcomeTyping).toBe(true)
|
||||
expect(store.startWelcomeTyping).toBe(true)
|
||||
})
|
||||
})
|
||||
81
neode-ui/src/stores/__tests__/screensaver.test.ts
Normal file
81
neode-ui/src/stores/__tests__/screensaver.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useScreensaverStore } from '../screensaver'
|
||||
|
||||
describe('useScreensaverStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('starts inactive', () => {
|
||||
const store = useScreensaverStore()
|
||||
expect(store.isActive).toBe(false)
|
||||
})
|
||||
|
||||
it('activate sets isActive to true', () => {
|
||||
const store = useScreensaverStore()
|
||||
store.activate()
|
||||
expect(store.isActive).toBe(true)
|
||||
})
|
||||
|
||||
it('deactivate sets isActive to false', () => {
|
||||
const store = useScreensaverStore()
|
||||
store.activate()
|
||||
store.deactivate()
|
||||
expect(store.isActive).toBe(false)
|
||||
})
|
||||
|
||||
it('deactivate starts inactivity timer that activates after 3 minutes', () => {
|
||||
const store = useScreensaverStore()
|
||||
store.deactivate()
|
||||
expect(store.isActive).toBe(false)
|
||||
|
||||
vi.advanceTimersByTime(3 * 60 * 1000)
|
||||
expect(store.isActive).toBe(true)
|
||||
})
|
||||
|
||||
it('resetInactivityTimer restarts the 3-minute countdown', () => {
|
||||
const store = useScreensaverStore()
|
||||
store.deactivate()
|
||||
|
||||
// Advance 2 minutes
|
||||
vi.advanceTimersByTime(2 * 60 * 1000)
|
||||
expect(store.isActive).toBe(false)
|
||||
|
||||
// Reset timer
|
||||
store.resetInactivityTimer()
|
||||
|
||||
// Advance another 2 minutes (would have triggered without reset)
|
||||
vi.advanceTimersByTime(2 * 60 * 1000)
|
||||
expect(store.isActive).toBe(false)
|
||||
|
||||
// Full 3 minutes from reset
|
||||
vi.advanceTimersByTime(1 * 60 * 1000)
|
||||
expect(store.isActive).toBe(true)
|
||||
})
|
||||
|
||||
it('clearInactivityTimer prevents activation', () => {
|
||||
const store = useScreensaverStore()
|
||||
store.deactivate()
|
||||
store.clearInactivityTimer()
|
||||
|
||||
vi.advanceTimersByTime(5 * 60 * 1000)
|
||||
expect(store.isActive).toBe(false)
|
||||
})
|
||||
|
||||
it('activate clears any pending timer', () => {
|
||||
const store = useScreensaverStore()
|
||||
store.deactivate()
|
||||
store.activate()
|
||||
|
||||
// If timer wasn't cleared, deactivating and waiting would trigger twice
|
||||
store.deactivate()
|
||||
vi.advanceTimersByTime(3 * 60 * 1000)
|
||||
expect(store.isActive).toBe(true)
|
||||
})
|
||||
})
|
||||
194
neode-ui/src/stores/__tests__/spotlight.test.ts
Normal file
194
neode-ui/src/stores/__tests__/spotlight.test.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
// Mock the nav sounds module
|
||||
vi.mock('@/composables/useNavSounds', () => ({
|
||||
playNavSound: vi.fn(),
|
||||
}))
|
||||
|
||||
import { useSpotlightStore } from '../spotlight'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
|
||||
const mockedPlayNavSound = vi.mocked(playNavSound)
|
||||
|
||||
describe('useSpotlightStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('starts closed with default state', () => {
|
||||
const store = useSpotlightStore()
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(store.selectedIndex).toBe(0)
|
||||
expect(store.recentItems).toEqual([])
|
||||
})
|
||||
|
||||
it('open sets isOpen to true and plays sound', () => {
|
||||
const store = useSpotlightStore()
|
||||
|
||||
store.open()
|
||||
|
||||
expect(store.isOpen).toBe(true)
|
||||
expect(store.selectedIndex).toBe(0)
|
||||
expect(mockedPlayNavSound).toHaveBeenCalledWith('action')
|
||||
})
|
||||
|
||||
it('close sets isOpen to false and resets index', () => {
|
||||
const store = useSpotlightStore()
|
||||
store.open()
|
||||
|
||||
store.close()
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(store.selectedIndex).toBe(0)
|
||||
})
|
||||
|
||||
it('toggle opens when closed', () => {
|
||||
const store = useSpotlightStore()
|
||||
|
||||
store.toggle()
|
||||
|
||||
expect(store.isOpen).toBe(true)
|
||||
})
|
||||
|
||||
it('toggle closes when open', () => {
|
||||
const store = useSpotlightStore()
|
||||
store.open()
|
||||
|
||||
store.toggle()
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('setSelectedIndex updates the selected index', () => {
|
||||
const store = useSpotlightStore()
|
||||
|
||||
store.setSelectedIndex(3)
|
||||
|
||||
expect(store.selectedIndex).toBe(3)
|
||||
})
|
||||
|
||||
describe('recent items', () => {
|
||||
it('addRecentItem adds item with timestamp', () => {
|
||||
const store = useSpotlightStore()
|
||||
|
||||
store.addRecentItem({ id: 'home', label: 'Home', path: '/dashboard', type: 'navigate' })
|
||||
|
||||
expect(store.recentItems).toHaveLength(1)
|
||||
expect(store.recentItems[0]!.id).toBe('home')
|
||||
expect(store.recentItems[0]!.label).toBe('Home')
|
||||
expect(store.recentItems[0]!.timestamp).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('addRecentItem deduplicates by id and type', () => {
|
||||
const store = useSpotlightStore()
|
||||
|
||||
store.addRecentItem({ id: 'home', label: 'Home', path: '/dashboard', type: 'navigate' })
|
||||
store.addRecentItem({ id: 'home', label: 'Home Updated', path: '/dashboard', type: 'navigate' })
|
||||
|
||||
expect(store.recentItems).toHaveLength(1)
|
||||
expect(store.recentItems[0]!.label).toBe('Home Updated')
|
||||
})
|
||||
|
||||
it('addRecentItem keeps different types with same id', () => {
|
||||
const store = useSpotlightStore()
|
||||
|
||||
store.addRecentItem({ id: 'bitcoin', label: 'Bitcoin (navigate)', type: 'navigate' })
|
||||
store.addRecentItem({ id: 'bitcoin', label: 'Bitcoin (action)', type: 'action' })
|
||||
|
||||
expect(store.recentItems).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('addRecentItem caps at 8 items', () => {
|
||||
const store = useSpotlightStore()
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
store.addRecentItem({ id: `item-${i}`, label: `Item ${i}`, type: 'navigate' })
|
||||
}
|
||||
|
||||
expect(store.recentItems).toHaveLength(8)
|
||||
// Most recent should be first
|
||||
expect(store.recentItems[0]!.id).toBe('item-9')
|
||||
})
|
||||
|
||||
it('addRecentItem persists to localStorage', () => {
|
||||
const store = useSpotlightStore()
|
||||
|
||||
store.addRecentItem({ id: 'apps', label: 'Apps', path: '/apps', type: 'navigate' })
|
||||
|
||||
const stored = JSON.parse(localStorage.getItem('archipelago-spotlight-recent')!)
|
||||
expect(stored).toHaveLength(1)
|
||||
expect(stored[0].id).toBe('apps')
|
||||
})
|
||||
|
||||
it('loadRecentItems reads from localStorage', () => {
|
||||
const saved = [
|
||||
{ id: 'home', label: 'Home', path: '/dashboard', type: 'navigate', timestamp: 1000 },
|
||||
{ id: 'apps', label: 'Apps', path: '/apps', type: 'navigate', timestamp: 2000 },
|
||||
]
|
||||
localStorage.setItem('archipelago-spotlight-recent', JSON.stringify(saved))
|
||||
|
||||
const store = useSpotlightStore()
|
||||
store.loadRecentItems()
|
||||
|
||||
expect(store.recentItems).toHaveLength(2)
|
||||
expect(store.recentItems[0]!.id).toBe('home')
|
||||
})
|
||||
|
||||
it('loadRecentItems handles corrupt localStorage', () => {
|
||||
localStorage.setItem('archipelago-spotlight-recent', 'not-json{{{')
|
||||
|
||||
const store = useSpotlightStore()
|
||||
store.loadRecentItems()
|
||||
|
||||
expect(store.recentItems).toEqual([])
|
||||
})
|
||||
|
||||
it('loadRecentItems handles empty localStorage', () => {
|
||||
const store = useSpotlightStore()
|
||||
store.loadRecentItems()
|
||||
|
||||
expect(store.recentItems).toEqual([])
|
||||
})
|
||||
|
||||
it('open calls loadRecentItems', () => {
|
||||
const saved = [
|
||||
{ id: 'test', label: 'Test', type: 'navigate', timestamp: 1000 },
|
||||
]
|
||||
localStorage.setItem('archipelago-spotlight-recent', JSON.stringify(saved))
|
||||
|
||||
const store = useSpotlightStore()
|
||||
store.open()
|
||||
|
||||
expect(store.recentItems).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('help modal', () => {
|
||||
it('showHelpModal opens the modal with content', () => {
|
||||
const store = useSpotlightStore()
|
||||
|
||||
store.showHelpModal({
|
||||
title: 'What is Bitcoin?',
|
||||
content: 'A peer-to-peer electronic cash system.',
|
||||
relatedPath: '/apps/bitcoin',
|
||||
})
|
||||
|
||||
expect(store.helpModal.show).toBe(true)
|
||||
expect(store.helpModal.title).toBe('What is Bitcoin?')
|
||||
expect(store.helpModal.content).toBe('A peer-to-peer electronic cash system.')
|
||||
expect(store.helpModal.relatedPath).toBe('/apps/bitcoin')
|
||||
})
|
||||
|
||||
it('closeHelpModal closes the modal', () => {
|
||||
const store = useSpotlightStore()
|
||||
store.showHelpModal({ title: 'Test', content: 'Content' })
|
||||
|
||||
store.closeHelpModal()
|
||||
|
||||
expect(store.helpModal.show).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
90
neode-ui/src/stores/__tests__/uiMode.test.ts
Normal file
90
neode-ui/src/stores/__tests__/uiMode.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useUIModeStore } from '../uiMode'
|
||||
|
||||
describe('useUIModeStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('defaults to gamer mode when no stored value', () => {
|
||||
const store = useUIModeStore()
|
||||
expect(store.mode).toBe('gamer')
|
||||
expect(store.isGamer).toBe(true)
|
||||
expect(store.isEasy).toBe(false)
|
||||
expect(store.isChat).toBe(false)
|
||||
})
|
||||
|
||||
it('loads stored mode from localStorage', () => {
|
||||
localStorage.setItem('archipelago-ui-mode', 'easy')
|
||||
const store = useUIModeStore()
|
||||
expect(store.mode).toBe('easy')
|
||||
expect(store.isEasy).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores invalid localStorage values', () => {
|
||||
localStorage.setItem('archipelago-ui-mode', 'invalid-mode')
|
||||
const store = useUIModeStore()
|
||||
expect(store.mode).toBe('gamer')
|
||||
})
|
||||
|
||||
it('setMode updates mode and persists', () => {
|
||||
const store = useUIModeStore()
|
||||
store.setMode('easy')
|
||||
expect(store.mode).toBe('easy')
|
||||
expect(store.isEasy).toBe(true)
|
||||
expect(localStorage.getItem('archipelago-ui-mode')).toBe('easy')
|
||||
})
|
||||
|
||||
it('setMode to chat mode', () => {
|
||||
const store = useUIModeStore()
|
||||
store.setMode('chat')
|
||||
expect(store.mode).toBe('chat')
|
||||
expect(store.isChat).toBe(true)
|
||||
expect(store.isGamer).toBe(false)
|
||||
})
|
||||
|
||||
it('cycleMode cycles between easy and gamer', () => {
|
||||
const store = useUIModeStore()
|
||||
// Start at gamer
|
||||
expect(store.mode).toBe('gamer')
|
||||
|
||||
// Cycle to easy
|
||||
const next1 = store.cycleMode()
|
||||
expect(next1).toBe('easy')
|
||||
expect(store.mode).toBe('easy')
|
||||
|
||||
// Cycle back to gamer (wraps after easy since order is [easy, gamer])
|
||||
const next2 = store.cycleMode()
|
||||
expect(next2).toBe('gamer')
|
||||
expect(store.mode).toBe('gamer')
|
||||
})
|
||||
|
||||
it('cycleMode from chat wraps to easy', () => {
|
||||
const store = useUIModeStore()
|
||||
store.setMode('chat')
|
||||
const next = store.cycleMode()
|
||||
// chat is not in the order array, so idx=-1, next = order[0] = easy
|
||||
expect(next).toBe('easy')
|
||||
})
|
||||
|
||||
it('syncFromBackend updates mode from backend', () => {
|
||||
const store = useUIModeStore()
|
||||
store.syncFromBackend('easy')
|
||||
expect(store.mode).toBe('easy')
|
||||
expect(localStorage.getItem('archipelago-ui-mode')).toBe('easy')
|
||||
})
|
||||
|
||||
it('syncFromBackend ignores invalid modes', () => {
|
||||
const store = useUIModeStore()
|
||||
store.syncFromBackend('invalid' as 'gamer')
|
||||
expect(store.mode).toBe('gamer') // unchanged
|
||||
})
|
||||
|
||||
it('syncFromBackend ignores undefined', () => {
|
||||
const store = useUIModeStore()
|
||||
store.syncFromBackend(undefined)
|
||||
expect(store.mode).toBe('gamer') // unchanged
|
||||
})
|
||||
})
|
||||
78
neode-ui/src/stores/__tests__/web5Badge.test.ts
Normal file
78
neode-ui/src/stores/__tests__/web5Badge.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useWeb5BadgeStore } from '../web5Badge'
|
||||
|
||||
// Mock rpcClient
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
describe('useWeb5BadgeStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('starts with zero pending requests', () => {
|
||||
const store = useWeb5BadgeStore()
|
||||
expect(store.pendingRequestCount).toBe(0)
|
||||
})
|
||||
|
||||
it('refresh updates count from API', async () => {
|
||||
vi.mocked(rpcClient.call).mockResolvedValueOnce({
|
||||
requests: [{ id: '1' }, { id: '2' }, { id: '3' }],
|
||||
})
|
||||
|
||||
const store = useWeb5BadgeStore()
|
||||
await store.refresh()
|
||||
|
||||
expect(store.pendingRequestCount).toBe(3)
|
||||
expect(rpcClient.call).toHaveBeenCalledWith({ method: 'network.list-requests' })
|
||||
})
|
||||
|
||||
it('refresh handles empty requests', async () => {
|
||||
vi.mocked(rpcClient.call).mockResolvedValueOnce({ requests: [] })
|
||||
|
||||
const store = useWeb5BadgeStore()
|
||||
await store.refresh()
|
||||
|
||||
expect(store.pendingRequestCount).toBe(0)
|
||||
})
|
||||
|
||||
it('refresh handles null requests gracefully', async () => {
|
||||
vi.mocked(rpcClient.call).mockResolvedValueOnce({ requests: null })
|
||||
|
||||
const store = useWeb5BadgeStore()
|
||||
await store.refresh()
|
||||
|
||||
expect(store.pendingRequestCount).toBe(0)
|
||||
})
|
||||
|
||||
it('refresh handles API error gracefully', async () => {
|
||||
vi.mocked(rpcClient.call).mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const store = useWeb5BadgeStore()
|
||||
store.pendingRequestCount = 5 // pre-existing value
|
||||
await store.refresh()
|
||||
|
||||
// Should not throw, count stays at pre-existing value (error swallowed)
|
||||
expect(store.pendingRequestCount).toBe(5)
|
||||
})
|
||||
|
||||
it('refresh updates count on subsequent calls', async () => {
|
||||
vi.mocked(rpcClient.call)
|
||||
.mockResolvedValueOnce({ requests: [{ id: '1' }] })
|
||||
.mockResolvedValueOnce({ requests: [{ id: '1' }, { id: '2' }] })
|
||||
|
||||
const store = useWeb5BadgeStore()
|
||||
await store.refresh()
|
||||
expect(store.pendingRequestCount).toBe(1)
|
||||
|
||||
await store.refresh()
|
||||
expect(store.pendingRequestCount).toBe(2)
|
||||
})
|
||||
})
|
||||
147
neode-ui/src/stores/aiPermissions.ts
Normal file
147
neode-ui/src/stores/aiPermissions.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { AIContextCategory } from '@/types/aiui-protocol'
|
||||
|
||||
const STORAGE_KEY = 'archipelago-ai-permissions'
|
||||
|
||||
export interface AIPermissionCategory {
|
||||
id: AIContextCategory
|
||||
label: string
|
||||
description: string
|
||||
icon: string
|
||||
group: string
|
||||
}
|
||||
|
||||
export const AI_PERMISSION_CATEGORIES: AIPermissionCategory[] = [
|
||||
{
|
||||
id: 'apps',
|
||||
label: 'Installed Apps',
|
||||
description: 'App names, status, and health — no credentials or config details',
|
||||
icon: 'M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z',
|
||||
group: 'Node Data',
|
||||
},
|
||||
{
|
||||
id: 'system',
|
||||
label: 'System Stats',
|
||||
description: 'CPU, RAM, disk usage — no file paths or IP addresses',
|
||||
icon: 'M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z',
|
||||
group: 'Node Data',
|
||||
},
|
||||
{
|
||||
id: 'network',
|
||||
label: 'Network Status',
|
||||
description: 'Connection status, peer count — no IP addresses or keys',
|
||||
icon: 'M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01',
|
||||
group: 'Node Data',
|
||||
},
|
||||
{
|
||||
id: 'bitcoin',
|
||||
label: 'Bitcoin Node',
|
||||
description: 'Block height, sync progress, mempool stats — no wallet keys',
|
||||
icon: 'M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
group: 'Node Data',
|
||||
},
|
||||
{
|
||||
id: 'media',
|
||||
label: 'Media Libraries',
|
||||
description: 'Local media libraries — film, music, podcast titles and metadata, no file paths',
|
||||
icon: 'M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z',
|
||||
group: 'Media & Files',
|
||||
},
|
||||
{
|
||||
id: 'files',
|
||||
label: 'File Names',
|
||||
description: 'Folder and file names in Cloud — no file contents',
|
||||
icon: 'M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z',
|
||||
group: 'Media & Files',
|
||||
},
|
||||
{
|
||||
id: 'notes',
|
||||
label: 'Documents & Notes',
|
||||
description: 'Document and note titles — no contents',
|
||||
icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z',
|
||||
group: 'Media & Files',
|
||||
},
|
||||
{
|
||||
id: 'search',
|
||||
label: 'Web Search',
|
||||
description: 'Web search via your private SearXNG instance',
|
||||
icon: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z',
|
||||
group: 'AI & Search',
|
||||
},
|
||||
{
|
||||
id: 'ai-local',
|
||||
label: 'Local AI Models',
|
||||
description: 'Local AI models via Ollama — model names and availability',
|
||||
icon: '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',
|
||||
group: 'AI & Search',
|
||||
},
|
||||
{
|
||||
id: 'wallet',
|
||||
label: 'Wallet Overview',
|
||||
description: 'Balance, channel count — no private keys, seeds, or addresses',
|
||||
icon: 'M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z',
|
||||
group: 'Financial',
|
||||
},
|
||||
]
|
||||
|
||||
export const useAIPermissionsStore = defineStore('aiPermissions', () => {
|
||||
const enabled = ref<Set<AIContextCategory>>(loadFromStorage())
|
||||
|
||||
function loadFromStorage(): Set<AIContextCategory> {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as AIContextCategory[]
|
||||
return new Set(parsed.filter(c => AI_PERMISSION_CATEGORIES.some(cat => cat.id === c)))
|
||||
}
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('Failed to load AI permissions from storage', e)
|
||||
}
|
||||
return new Set()
|
||||
}
|
||||
|
||||
function save() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify([...enabled.value]))
|
||||
}
|
||||
|
||||
function isEnabled(category: AIContextCategory): boolean {
|
||||
return enabled.value.has(category)
|
||||
}
|
||||
|
||||
function toggle(category: AIContextCategory) {
|
||||
if (enabled.value.has(category)) {
|
||||
enabled.value.delete(category)
|
||||
} else {
|
||||
enabled.value.add(category)
|
||||
}
|
||||
// Trigger reactivity
|
||||
enabled.value = new Set(enabled.value)
|
||||
save()
|
||||
}
|
||||
|
||||
function enableAll() {
|
||||
enabled.value = new Set(AI_PERMISSION_CATEGORIES.map(c => c.id))
|
||||
save()
|
||||
}
|
||||
|
||||
function disableAll() {
|
||||
enabled.value = new Set()
|
||||
save()
|
||||
}
|
||||
|
||||
const enabledCategories = computed(() => [...enabled.value])
|
||||
const allEnabled = computed(() => enabled.value.size === AI_PERMISSION_CATEGORIES.length)
|
||||
const noneEnabled = computed(() => enabled.value.size === 0)
|
||||
|
||||
return {
|
||||
enabled,
|
||||
isEnabled,
|
||||
toggle,
|
||||
enableAll,
|
||||
disableAll,
|
||||
enabledCategories,
|
||||
allEnabled,
|
||||
noneEnabled,
|
||||
}
|
||||
})
|
||||
317
neode-ui/src/stores/app.ts
Normal file
317
neode-ui/src/stores/app.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
// Main application store using Pinia
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { DataModel } from '../types/api'
|
||||
import { wsClient, applyDataPatch } from '../api/websocket'
|
||||
import { rpcClient } from '../api/rpc-client'
|
||||
|
||||
export const useAppStore = defineStore('app', () => {
|
||||
// State
|
||||
const data = ref<DataModel | null>(null)
|
||||
const isAuthenticated = ref(localStorage.getItem('neode-auth') === 'true')
|
||||
const isConnected = ref(false)
|
||||
const isReconnecting = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
let isWsSubscribed = false
|
||||
let sessionValidated = false
|
||||
|
||||
// Computed
|
||||
const serverInfo = computed(() => data.value?.['server-info'])
|
||||
const packages = computed(() => data.value?.['package-data'] || {})
|
||||
const peerHealth = computed<Record<string, boolean>>(() => data.value?.['peer-health'] || {})
|
||||
const uiData = computed(() => data.value?.ui)
|
||||
const serverName = computed(() => serverInfo.value?.name || 'Archipelago')
|
||||
const isRestarting = computed(() => serverInfo.value?.['status-info']?.restarting || false)
|
||||
const isShuttingDown = computed(() => serverInfo.value?.['status-info']?.['shutting-down'] || false)
|
||||
const isOffline = computed(() => !isConnected.value || isRestarting.value || isShuttingDown.value)
|
||||
|
||||
// Actions
|
||||
async function login(password: string): Promise<{ requires_totp?: boolean }> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const result = await rpcClient.login(password)
|
||||
if (result && result.requires_totp) {
|
||||
return { requires_totp: true }
|
||||
}
|
||||
|
||||
isAuthenticated.value = true
|
||||
sessionValidated = true
|
||||
localStorage.setItem('neode-auth', 'true')
|
||||
|
||||
// Initialize data structure immediately so dashboard can render
|
||||
await initializeData()
|
||||
|
||||
// Connect WebSocket in background - don't block login flow
|
||||
connectWebSocket().catch((err) => {
|
||||
if (import.meta.env.DEV) console.warn('[Store] WebSocket connection failed after login, will retry:', err)
|
||||
})
|
||||
return {}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Login failed'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function completeLoginAfterTotp(): Promise<void> {
|
||||
isAuthenticated.value = true
|
||||
sessionValidated = true
|
||||
localStorage.setItem('neode-auth', 'true')
|
||||
await initializeData()
|
||||
connectWebSocket().catch((err) => {
|
||||
if (import.meta.env.DEV) console.warn('[Store] WebSocket connection failed after TOTP login, will retry:', err)
|
||||
})
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
try {
|
||||
await rpcClient.logout()
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error('Logout error:', err)
|
||||
} finally {
|
||||
isAuthenticated.value = false
|
||||
sessionValidated = false
|
||||
localStorage.removeItem('neode-auth')
|
||||
data.value = null
|
||||
isWsSubscribed = false
|
||||
wsClient.disconnect()
|
||||
isConnected.value = false
|
||||
isReconnecting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function connectWebSocket(): Promise<void> {
|
||||
try {
|
||||
if (import.meta.env.DEV) console.log('[Store] Connecting WebSocket...')
|
||||
isReconnecting.value = true
|
||||
|
||||
// Don't create multiple subscriptions - check if already subscribed
|
||||
if (!isWsSubscribed) {
|
||||
// Subscribe to updates BEFORE connecting (so we catch initial data)
|
||||
isWsSubscribed = true
|
||||
|
||||
// Listen for connection state changes
|
||||
wsClient.onConnectionStateChange((state) => {
|
||||
if (import.meta.env.DEV) console.log('[Store] WebSocket connection state changed:', state)
|
||||
isConnected.value = state === 'connected'
|
||||
isReconnecting.value = state === 'connecting'
|
||||
})
|
||||
|
||||
wsClient.subscribe((update: { type?: string; data?: DataModel; rev?: number; patch?: import('@/types/api').PatchOperation[] }) => {
|
||||
// Handle mock backend format: {type: 'initial', data: {...}}
|
||||
if (update?.type === 'initial' && update?.data) {
|
||||
if (import.meta.env.DEV) console.log('[Store] Received initial data from mock backend')
|
||||
data.value = update.data
|
||||
isConnected.value = true
|
||||
isReconnecting.value = false
|
||||
}
|
||||
// Handle real backend format: {rev: 0, data: {...}}
|
||||
else if (update?.data && update?.rev !== undefined) {
|
||||
data.value = update.data
|
||||
isConnected.value = true
|
||||
isReconnecting.value = false
|
||||
}
|
||||
// Handle patch updates (both backends)
|
||||
else if (data.value && update?.patch) {
|
||||
try {
|
||||
if (import.meta.env.DEV) console.log('[Store] Applying patch at revision', update.rev || 'unknown')
|
||||
data.value = applyDataPatch(data.value, update.patch)
|
||||
// Mark as connected once we receive any valid patch
|
||||
if (!isConnected.value) {
|
||||
isConnected.value = true
|
||||
isReconnecting.value = false
|
||||
}
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error('[Store] Failed to apply WebSocket patch:', err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Now connect (or reconnect if already connected)
|
||||
// Only attempt to connect if not already connected
|
||||
if (wsClient.isConnected()) {
|
||||
if (import.meta.env.DEV) console.log('[Store] WebSocket already connected')
|
||||
isConnected.value = true
|
||||
isReconnecting.value = false
|
||||
return
|
||||
}
|
||||
|
||||
await wsClient.connect()
|
||||
if (import.meta.env.DEV) console.log('[Store] WebSocket connected')
|
||||
|
||||
// Connection state will be updated via the callback
|
||||
if (wsClient.isConnected()) {
|
||||
isConnected.value = true
|
||||
isReconnecting.value = false
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error('[Store] WebSocket connection failed:', err)
|
||||
// Don't mark as disconnected immediately - let reconnection logic handle it
|
||||
// The WebSocket client will retry automatically
|
||||
isReconnecting.value = true
|
||||
isConnected.value = false
|
||||
// Don't throw - allow app to work without real-time updates
|
||||
// The WebSocket will reconnect in the background
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeData(): Promise<void> {
|
||||
// Initialize with empty data structure
|
||||
// The WebSocket will populate it with real data
|
||||
data.value = {
|
||||
'server-info': {
|
||||
id: '',
|
||||
version: '',
|
||||
name: null,
|
||||
pubkey: '',
|
||||
'status-info': {
|
||||
restarting: false,
|
||||
'shutting-down': false,
|
||||
updated: false,
|
||||
'backup-progress': null,
|
||||
'update-progress': null,
|
||||
},
|
||||
'lan-address': null,
|
||||
'tor-address': null,
|
||||
unread: 0,
|
||||
'wifi-ssids': [],
|
||||
'zram-enabled': false,
|
||||
},
|
||||
'package-data': {},
|
||||
ui: {
|
||||
name: null,
|
||||
'ack-welcome': '',
|
||||
marketplace: {
|
||||
'selected-hosts': [],
|
||||
'known-hosts': {},
|
||||
},
|
||||
theme: 'dark',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Check session validity on app load or stale auth
|
||||
async function checkSession(): Promise<boolean> {
|
||||
if (!localStorage.getItem('neode-auth')) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
await rpcClient.call({ method: 'server.echo', params: { message: 'ping' } })
|
||||
isAuthenticated.value = true
|
||||
sessionValidated = true
|
||||
|
||||
await initializeData()
|
||||
|
||||
connectWebSocket().catch((err) => {
|
||||
if (import.meta.env.DEV) console.warn('[Store] WebSocket reconnection failed, will retry:', err)
|
||||
isReconnecting.value = true
|
||||
})
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error('[Store] Session check failed:', err)
|
||||
localStorage.removeItem('neode-auth')
|
||||
isAuthenticated.value = false
|
||||
sessionValidated = false
|
||||
isWsSubscribed = false
|
||||
isConnected.value = false
|
||||
isReconnecting.value = false
|
||||
wsClient.disconnect()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function needsSessionValidation(): boolean {
|
||||
return isAuthenticated.value && !sessionValidated
|
||||
}
|
||||
|
||||
// Package actions
|
||||
async function installPackage(id: string, marketplaceUrl: string, version: string): Promise<string> {
|
||||
return rpcClient.installPackage(id, marketplaceUrl, version)
|
||||
}
|
||||
|
||||
async function uninstallPackage(id: string): Promise<void> {
|
||||
return rpcClient.uninstallPackage(id)
|
||||
}
|
||||
|
||||
async function startPackage(id: string): Promise<void> {
|
||||
return rpcClient.startPackage(id)
|
||||
}
|
||||
|
||||
async function stopPackage(id: string): Promise<void> {
|
||||
return rpcClient.stopPackage(id)
|
||||
}
|
||||
|
||||
async function restartPackage(id: string): Promise<void> {
|
||||
return rpcClient.restartPackage(id)
|
||||
}
|
||||
|
||||
// Server actions
|
||||
async function updateServer(marketplaceUrl: string): Promise<'updating' | 'no-updates'> {
|
||||
return rpcClient.updateServer(marketplaceUrl)
|
||||
}
|
||||
|
||||
async function restartServer(): Promise<void> {
|
||||
return rpcClient.restartServer()
|
||||
}
|
||||
|
||||
async function shutdownServer(): Promise<void> {
|
||||
return rpcClient.shutdownServer()
|
||||
}
|
||||
|
||||
async function getMetrics(): Promise<Record<string, unknown>> {
|
||||
return rpcClient.getMetrics()
|
||||
}
|
||||
|
||||
// Marketplace actions
|
||||
async function getMarketplace(url: string): Promise<Record<string, unknown>> {
|
||||
return rpcClient.getMarketplace(url)
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
data,
|
||||
isAuthenticated,
|
||||
isConnected,
|
||||
isReconnecting,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// Computed
|
||||
serverInfo,
|
||||
packages,
|
||||
peerHealth,
|
||||
uiData,
|
||||
serverName,
|
||||
isRestarting,
|
||||
isShuttingDown,
|
||||
isOffline,
|
||||
|
||||
// Actions
|
||||
login,
|
||||
completeLoginAfterTotp,
|
||||
logout,
|
||||
checkSession,
|
||||
needsSessionValidation,
|
||||
connectWebSocket,
|
||||
installPackage,
|
||||
uninstallPackage,
|
||||
startPackage,
|
||||
stopPackage,
|
||||
restartPackage,
|
||||
updateServer,
|
||||
restartServer,
|
||||
shutdownServer,
|
||||
getMetrics,
|
||||
getMarketplace,
|
||||
}
|
||||
})
|
||||
|
||||
310
neode-ui/src/stores/appLauncher.ts
Normal file
310
neode-ui/src/stores/appLauncher.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import router from '@/router'
|
||||
|
||||
/** Ports of apps that set X-Frame-Options (can't iframe, must open in new tab) */
|
||||
const NEW_TAB_PORTS = new Set([
|
||||
'23000', // BTCPay — X-Frame-Options: DENY
|
||||
'3000', // Grafana — X-Frame-Options: deny
|
||||
'2342', // PhotoPrism — X-Frame-Options: DENY
|
||||
'8123', // Home Assistant — X-Frame-Options: SAMEORIGIN
|
||||
'8082', // Vaultwarden — X-Frame-Options: SAMEORIGIN
|
||||
'8085', // Nextcloud — X-Frame-Options: SAMEORIGIN
|
||||
'3001', // Uptime Kuma — X-Frame-Options: SAMEORIGIN
|
||||
'9001', // Penpot — not reachable
|
||||
// IndeedHub (7777) uses proxy path for NIP-07 nostr-provider.js — NOT new tab
|
||||
])
|
||||
|
||||
function mustOpenInNewTab(url: string): boolean {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
return NEW_TAB_PORTS.has(u.port)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Port → app ID for resolving URLs to AppSession routes */
|
||||
const PORT_TO_APP_ID: Record<string, string> = {
|
||||
'81': 'nginx-proxy-manager',
|
||||
'3000': 'grafana',
|
||||
'3001': 'uptime-kuma',
|
||||
'8080': 'endurain',
|
||||
'8081': 'lnd',
|
||||
'8082': 'vaultwarden',
|
||||
'8083': 'filebrowser',
|
||||
'8085': 'nextcloud',
|
||||
'8096': 'jellyfin',
|
||||
'8123': 'homeassistant',
|
||||
'8240': 'tailscale',
|
||||
'8334': 'bitcoin-knots',
|
||||
'8888': 'searxng',
|
||||
'9000': 'portainer',
|
||||
'9001': 'penpot',
|
||||
'9980': 'onlyoffice',
|
||||
'11434': 'ollama',
|
||||
'2283': 'immich',
|
||||
'23000': 'btcpay-server',
|
||||
'2342': 'photoprism',
|
||||
'4080': 'mempool',
|
||||
'8175': 'fedimint',
|
||||
'8176': 'fedimint-gateway',
|
||||
'3100': 'dwn',
|
||||
'18081': 'nostr-rs-relay',
|
||||
'7777': 'indeedhub',
|
||||
'50002': 'electrumx',
|
||||
}
|
||||
|
||||
|
||||
const APPROVED_ORIGINS_KEY = 'neode_nostr_approved_origins'
|
||||
|
||||
function getApprovedOrigins(): Set<string> {
|
||||
try {
|
||||
const stored = localStorage.getItem(APPROVED_ORIGINS_KEY)
|
||||
return stored ? new Set(JSON.parse(stored) as string[]) : new Set()
|
||||
} catch {
|
||||
return new Set()
|
||||
}
|
||||
}
|
||||
|
||||
function saveApprovedOrigin(origin: string) {
|
||||
const origins = getApprovedOrigins()
|
||||
origins.add(origin)
|
||||
localStorage.setItem(APPROVED_ORIGINS_KEY, JSON.stringify([...origins]))
|
||||
}
|
||||
|
||||
export interface NostrConsentRequest {
|
||||
appName: string
|
||||
method: string
|
||||
eventKind?: number
|
||||
content?: string
|
||||
resolve: (remember: boolean) => void
|
||||
reject: () => void
|
||||
}
|
||||
|
||||
const DISPLAY_MODE_KEY = 'archipelago_app_display_mode'
|
||||
|
||||
export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
const isOpen = ref(false)
|
||||
const url = ref('')
|
||||
const title = ref('')
|
||||
const consentRequest = ref<NostrConsentRequest | null>(null)
|
||||
const showConsent = ref(false)
|
||||
let previousActiveElement: HTMLElement | null = null
|
||||
|
||||
/** Active app in panel mode (store-based, no route change) */
|
||||
const panelAppId = ref<string | null>(null)
|
||||
|
||||
/** Open app in session view — panel mode uses store, overlay/fullscreen uses route */
|
||||
function openSession(appId: string) {
|
||||
const mode = localStorage.getItem(DISPLAY_MODE_KEY) || 'panel'
|
||||
if (mode === 'panel') {
|
||||
panelAppId.value = appId
|
||||
} else {
|
||||
panelAppId.value = null
|
||||
router.push({ name: 'app-session', params: { appId } })
|
||||
}
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
panelAppId.value = null
|
||||
}
|
||||
|
||||
/** Legacy: open app in iframe overlay (kept for backward compat) */
|
||||
function open(payload: { url: string; title: string; openInNewTab?: boolean }) {
|
||||
// Route to full-page session if we can resolve an app ID from the URL
|
||||
const resolvedId = resolveAppIdFromUrl(payload.url)
|
||||
if (resolvedId) {
|
||||
openSession(resolvedId)
|
||||
return
|
||||
}
|
||||
// Apps that block iframes — open directly in new tab
|
||||
if (payload.openInNewTab || mustOpenInNewTab(payload.url)) {
|
||||
window.open(payload.url, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
previousActiveElement = (document.activeElement as HTMLElement) || null
|
||||
url.value = payload.url
|
||||
title.value = payload.title
|
||||
isOpen.value = true
|
||||
}
|
||||
|
||||
/** Resolve an app ID from a URL (port or known external) */
|
||||
function resolveAppIdFromUrl(urlStr: string): string | null {
|
||||
try {
|
||||
const u = new URL(urlStr)
|
||||
// Check port-based apps
|
||||
const appId = PORT_TO_APP_ID[u.port]
|
||||
if (appId) return appId
|
||||
// Check external URLs
|
||||
const EXTERNAL_APP_HOSTS: Record<string, string> = {
|
||||
'botfights.net': 'botfights',
|
||||
'nwnn.l484.com': 'nwnn',
|
||||
'484.kitchen': '484-kitchen',
|
||||
'cta.tx1138.com': 'call-the-operator',
|
||||
'present.l484.com': 'arch-presentation',
|
||||
'syntropy.institute': 'syntropy-institute',
|
||||
'teeminuszero.net': 't-zero',
|
||||
'nostrudel.ninja': 'nostrudel',
|
||||
}
|
||||
return EXTERNAL_APP_HOSTS[u.hostname] || null
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
function close() {
|
||||
const toRestore = previousActiveElement
|
||||
previousActiveElement = null
|
||||
isOpen.value = false
|
||||
url.value = ''
|
||||
title.value = ''
|
||||
if (toRestore && typeof toRestore.focus === 'function') {
|
||||
requestAnimationFrame(() => {
|
||||
toRestore.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function approveConsent(remember: boolean) {
|
||||
if (consentRequest.value) {
|
||||
consentRequest.value.resolve(remember)
|
||||
consentRequest.value = null
|
||||
}
|
||||
showConsent.value = false
|
||||
}
|
||||
|
||||
function denyConsent() {
|
||||
if (consentRequest.value) {
|
||||
consentRequest.value.reject()
|
||||
consentRequest.value = null
|
||||
}
|
||||
showConsent.value = false
|
||||
}
|
||||
|
||||
function requestConsent(appName: string, method: string, eventKind?: number, content?: string): Promise<boolean> {
|
||||
return new Promise((resolve, reject) => {
|
||||
consentRequest.value = { appName, method, eventKind, content, resolve, reject }
|
||||
showConsent.value = true
|
||||
})
|
||||
}
|
||||
|
||||
// NIP-07 postMessage handler — responds to nostr-request from iframe apps
|
||||
async function handleNostrRequest(event: MessageEvent) {
|
||||
if (!event.data || event.data.type !== 'nostr-request') return
|
||||
const { id, method, params } = event.data
|
||||
const source = event.source as Window | null
|
||||
if (!source) return
|
||||
|
||||
const origin = url.value || 'unknown'
|
||||
|
||||
// Check if app has a per-app identity stored (from identity picker)
|
||||
const IDENTITY_KEY = 'archipelago_app_identity_'
|
||||
const appKey = IDENTITY_KEY + (url.value || '').replace(/[^a-z0-9]/gi, '_')
|
||||
let appIdentityId: string | null = null
|
||||
try {
|
||||
const stored = localStorage.getItem(appKey)
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as { id?: string }
|
||||
appIdentityId = parsed.id || null
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
try {
|
||||
let result: unknown
|
||||
|
||||
if (method === 'getPublicKey') {
|
||||
if (appIdentityId) {
|
||||
// Use the app-specific identity's Nostr key
|
||||
const res = await rpcClient.call<{ nostr_pubkey: string; nostr_npub: string; id: string; name: string; pubkey: string; did: string; is_default: boolean }>({
|
||||
method: 'identity.get', params: { id: appIdentityId }
|
||||
})
|
||||
result = res.nostr_pubkey
|
||||
} else {
|
||||
const res = await rpcClient.call<{ nostr_pubkey: string }>({ method: 'node.nostr-pubkey' })
|
||||
result = res.nostr_pubkey
|
||||
}
|
||||
} else if (method === 'signEvent') {
|
||||
// Check if origin is pre-approved
|
||||
const approved = getApprovedOrigins()
|
||||
if (!approved.has(origin)) {
|
||||
const eventKind = params?.event?.kind as number | undefined
|
||||
const content = params?.event?.content as string | undefined
|
||||
try {
|
||||
const remember = await requestConsent(title.value || 'App', 'signEvent', eventKind, content)
|
||||
if (remember) saveApprovedOrigin(origin)
|
||||
} catch {
|
||||
source.postMessage({ type: 'nostr-response', id, error: 'User denied signing request' }, '*')
|
||||
return
|
||||
}
|
||||
}
|
||||
if (appIdentityId) {
|
||||
// Sign with the app-specific identity's Nostr key
|
||||
const res = await rpcClient.call<unknown>({
|
||||
method: 'identity.nostr-sign',
|
||||
params: { id: appIdentityId, event: params.event }
|
||||
})
|
||||
result = res
|
||||
} else {
|
||||
const res = await rpcClient.call<unknown>({ method: 'node.nostr-sign', params: { event: params.event } })
|
||||
result = res
|
||||
}
|
||||
} else if (method === 'getRelays') {
|
||||
result = {}
|
||||
} else if (method === 'nip04.encrypt') {
|
||||
const res = await rpcClient.call<{ ciphertext: string }>({
|
||||
method: 'identity.nostr-encrypt-nip04',
|
||||
params: { id: appIdentityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext }
|
||||
})
|
||||
result = res.ciphertext
|
||||
} else if (method === 'nip04.decrypt') {
|
||||
const res = await rpcClient.call<{ plaintext: string }>({
|
||||
method: 'identity.nostr-decrypt-nip04',
|
||||
params: { id: appIdentityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext }
|
||||
})
|
||||
result = res.plaintext
|
||||
} else if (method === 'nip44.encrypt') {
|
||||
const res = await rpcClient.call<{ ciphertext: string }>({
|
||||
method: 'identity.nostr-encrypt-nip44',
|
||||
params: { id: appIdentityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext }
|
||||
})
|
||||
result = res.ciphertext
|
||||
} else if (method === 'nip44.decrypt') {
|
||||
const res = await rpcClient.call<{ plaintext: string }>({
|
||||
method: 'identity.nostr-decrypt-nip44',
|
||||
params: { id: appIdentityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext }
|
||||
})
|
||||
result = res.plaintext
|
||||
} else {
|
||||
throw new Error(`Unsupported NIP-07 method: ${method}`)
|
||||
}
|
||||
source.postMessage({ type: 'nostr-response', id, result }, '*')
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||
source.postMessage({ type: 'nostr-response', id, error: message }, '*')
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for NIP-07 requests only while an app is open
|
||||
watch(isOpen, (open) => {
|
||||
if (open) {
|
||||
window.addEventListener('message', handleNostrRequest)
|
||||
} else {
|
||||
window.removeEventListener('message', handleNostrRequest)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
url,
|
||||
title,
|
||||
open,
|
||||
openSession,
|
||||
close,
|
||||
closePanel,
|
||||
panelAppId,
|
||||
showConsent,
|
||||
consentRequest,
|
||||
approveConsent,
|
||||
denyConsent,
|
||||
}
|
||||
})
|
||||
29
neode-ui/src/stores/cli.ts
Normal file
29
neode-ui/src/stores/cli.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
|
||||
export const useCLIStore = defineStore('cli', () => {
|
||||
const isOpen = ref(false)
|
||||
|
||||
function open() {
|
||||
isOpen.value = true
|
||||
playNavSound('action')
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
const wasOpen = isOpen.value
|
||||
isOpen.value = !wasOpen
|
||||
if (!wasOpen) playNavSound('action')
|
||||
}
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
open,
|
||||
close,
|
||||
toggle,
|
||||
}
|
||||
})
|
||||
131
neode-ui/src/stores/cloud.ts
Normal file
131
neode-ui/src/stores/cloud.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { fileBrowserClient, type FileBrowserItem } from '@/api/filebrowser-client'
|
||||
|
||||
export const useCloudStore = defineStore('cloud', () => {
|
||||
const currentPath = ref('/')
|
||||
const items = ref<FileBrowserItem[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const authenticated = ref(false)
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
const parts = currentPath.value.split('/').filter(Boolean)
|
||||
const crumbs = [{ name: 'Home', path: '/' }]
|
||||
let path = ''
|
||||
for (const part of parts) {
|
||||
path += `/${part}`
|
||||
crumbs.push({ name: part, path })
|
||||
}
|
||||
return crumbs
|
||||
})
|
||||
|
||||
const sortedItems = computed(() => {
|
||||
const dirs = items.value.filter((i) => i.isDir)
|
||||
const files = items.value.filter((i) => !i.isDir)
|
||||
dirs.sort((a, b) => a.name.localeCompare(b.name))
|
||||
files.sort((a, b) => a.name.localeCompare(b.name))
|
||||
return [...dirs, ...files]
|
||||
})
|
||||
|
||||
async function init(): Promise<boolean> {
|
||||
if (authenticated.value) return true
|
||||
const ok = await fileBrowserClient.login()
|
||||
authenticated.value = ok
|
||||
return ok
|
||||
}
|
||||
|
||||
async function navigate(path: string): Promise<void> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
if (!authenticated.value) {
|
||||
const ok = await init()
|
||||
if (!ok) {
|
||||
error.value = 'Failed to authenticate with File Browser'
|
||||
return
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = await fileBrowserClient.listDirectory(path)
|
||||
items.value = result
|
||||
currentPath.value = path
|
||||
} catch {
|
||||
// Directory may not exist — try to create it, then retry
|
||||
if (path !== '/') {
|
||||
try {
|
||||
const parentPath = path.substring(0, path.lastIndexOf('/')) || '/'
|
||||
const dirName = path.substring(path.lastIndexOf('/') + 1)
|
||||
await fileBrowserClient.createFolder(parentPath, dirName)
|
||||
const result = await fileBrowserClient.listDirectory(path)
|
||||
items.value = result
|
||||
currentPath.value = path
|
||||
} catch {
|
||||
// Fall back to root
|
||||
const result = await fileBrowserClient.listDirectory('/')
|
||||
items.value = result
|
||||
currentPath.value = '/'
|
||||
}
|
||||
} else {
|
||||
throw new Error('Failed to list root directory')
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to load files'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
await navigate(currentPath.value)
|
||||
}
|
||||
|
||||
async function uploadFile(file: File): Promise<void> {
|
||||
await fileBrowserClient.upload(currentPath.value, file)
|
||||
await refresh()
|
||||
}
|
||||
|
||||
async function deleteItem(path: string): Promise<void> {
|
||||
await fileBrowserClient.deleteItem(path)
|
||||
await refresh()
|
||||
}
|
||||
|
||||
function downloadUrl(path: string): string {
|
||||
return fileBrowserClient.downloadUrl(path)
|
||||
}
|
||||
|
||||
async function fetchBlobUrl(path: string): Promise<string> {
|
||||
return fileBrowserClient.fetchBlobUrl(path)
|
||||
}
|
||||
|
||||
async function downloadFile(path: string): Promise<void> {
|
||||
return fileBrowserClient.downloadFile(path)
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
currentPath.value = '/'
|
||||
items.value = []
|
||||
loading.value = false
|
||||
error.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
currentPath,
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
authenticated,
|
||||
breadcrumbs,
|
||||
sortedItems,
|
||||
init,
|
||||
navigate,
|
||||
refresh,
|
||||
uploadFile,
|
||||
deleteItem,
|
||||
downloadUrl,
|
||||
fetchBlobUrl,
|
||||
downloadFile,
|
||||
reset,
|
||||
}
|
||||
})
|
||||
312
neode-ui/src/stores/container.ts
Normal file
312
neode-ui/src/stores/container.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
// Pinia store for container management
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { containerClient, type ContainerStatus } from '@/api/container-client'
|
||||
|
||||
// Bundled apps that come pre-loaded with Archipelago
|
||||
export interface BundledApp {
|
||||
id: string
|
||||
name: string
|
||||
image: string
|
||||
description: string
|
||||
icon: string
|
||||
ports: { host: number; container: number }[]
|
||||
volumes: { host: string; container: string }[]
|
||||
category: 'bitcoin' | 'lightning' | 'home' | 'other'
|
||||
lan_address?: string // Runtime launch URL from backend
|
||||
}
|
||||
|
||||
/** Map bundled app ID to the podman container name(s) used for status matching.
|
||||
* Some apps have a different container name than their app ID, or use a
|
||||
* separate UI container (e.g., bitcoin-knots node → bitcoin-ui web container). */
|
||||
const CONTAINER_NAME_MAP: Record<string, string[]> = {
|
||||
'bitcoin-knots': ['bitcoin-knots', 'bitcoin-ui'],
|
||||
'lnd': ['lnd', 'archy-lnd-ui'],
|
||||
'btcpay-server': ['btcpay-server'],
|
||||
'mempool': ['archy-mempool-web'],
|
||||
'electrumx': ['archy-electrs-ui', 'electrumx', 'mempool-electrs'],
|
||||
}
|
||||
|
||||
export const BUNDLED_APPS: BundledApp[] = [
|
||||
{
|
||||
id: 'bitcoin-knots',
|
||||
name: 'Bitcoin Knots',
|
||||
image: 'localhost/bitcoinknots/bitcoin:29',
|
||||
description: 'Full Bitcoin node with additional features',
|
||||
icon: '₿',
|
||||
ports: [{ host: 8334, container: 80 }],
|
||||
volumes: [{ host: '/var/lib/archipelago/bitcoin', container: '/data' }],
|
||||
category: 'bitcoin',
|
||||
},
|
||||
{
|
||||
id: 'lnd',
|
||||
name: 'Lightning (LND)',
|
||||
image: 'docker.io/lightninglabs/lnd:v0.18.4-beta',
|
||||
description: 'Lightning Network Daemon for fast Bitcoin payments',
|
||||
icon: '⚡',
|
||||
ports: [{ host: 8081, container: 80 }],
|
||||
volumes: [{ host: '/var/lib/archipelago/lnd', container: '/root/.lnd' }],
|
||||
category: 'lightning',
|
||||
},
|
||||
{
|
||||
id: 'homeassistant',
|
||||
name: 'Home Assistant',
|
||||
image: 'ghcr.io/home-assistant/home-assistant:stable',
|
||||
description: 'Open source home automation platform',
|
||||
icon: '🏠',
|
||||
ports: [{ host: 8123, container: 8123 }],
|
||||
volumes: [{ host: '/var/lib/archipelago/homeassistant', container: '/config' }],
|
||||
category: 'home',
|
||||
},
|
||||
{
|
||||
id: 'btcpay-server',
|
||||
name: 'BTCPay Server',
|
||||
image: 'docker.io/btcpayserver/btcpayserver:latest',
|
||||
description: 'Self-hosted Bitcoin payment processor',
|
||||
icon: '💳',
|
||||
ports: [{ host: 23000, container: 49392 }],
|
||||
volumes: [{ host: '/var/lib/archipelago/btcpay', container: '/datadir' }],
|
||||
category: 'bitcoin',
|
||||
},
|
||||
{
|
||||
id: 'mempool',
|
||||
name: 'Mempool Explorer',
|
||||
image: 'docker.io/mempool/frontend:latest',
|
||||
description: 'Bitcoin blockchain and mempool visualizer',
|
||||
icon: '🔍',
|
||||
ports: [{ host: 4080, container: 8080 }],
|
||||
volumes: [{ host: '/var/lib/archipelago/mempool', container: '/data' }],
|
||||
category: 'bitcoin',
|
||||
},
|
||||
{
|
||||
id: 'tailscale',
|
||||
name: 'Tailscale VPN',
|
||||
image: 'docker.io/tailscale/tailscale:latest',
|
||||
description: 'Zero-config VPN mesh network',
|
||||
icon: '🔒',
|
||||
ports: [],
|
||||
volumes: [{ host: '/var/lib/archipelago/tailscale', container: '/var/lib/tailscale' }],
|
||||
category: 'other',
|
||||
},
|
||||
]
|
||||
|
||||
export const useContainerStore = defineStore('container', () => {
|
||||
// State
|
||||
const containers = ref<ContainerStatus[]>([])
|
||||
const healthStatus = ref<Record<string, string>>({})
|
||||
const loading = ref(false)
|
||||
const loadingApps = ref<Set<string>>(new Set()) // Track loading state per app
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Getters
|
||||
const runningContainers = computed(() =>
|
||||
containers.value.filter(c => c.state === 'running')
|
||||
)
|
||||
|
||||
const stoppedContainers = computed(() =>
|
||||
containers.value.filter(c => c.state === 'stopped' || c.state === 'exited')
|
||||
)
|
||||
|
||||
const getContainerById = computed(() => (id: string) =>
|
||||
containers.value.find(c => c.name.includes(id))
|
||||
)
|
||||
|
||||
const getHealthStatus = computed(() => (appId: string) =>
|
||||
healthStatus.value[appId] || 'unknown'
|
||||
)
|
||||
|
||||
// Get container for a bundled app (matches by explicit name map, then by exact name)
|
||||
const getContainerForApp = computed(() => (appId: string) => {
|
||||
const nameList = CONTAINER_NAME_MAP[appId]
|
||||
if (nameList) {
|
||||
// Try each known container name in priority order
|
||||
for (const n of nameList) {
|
||||
const found = containers.value.find(c => c.name === n)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
// Fallback: exact match on app ID
|
||||
return containers.value.find(c => c.name === appId)
|
||||
})
|
||||
|
||||
// Check if an app is currently loading (starting/stopping)
|
||||
const isAppLoading = computed(() => (appId: string) =>
|
||||
loadingApps.value.has(appId)
|
||||
)
|
||||
|
||||
// Get app state: 'running', 'stopped', 'not-installed'
|
||||
const getAppState = computed(() => (appId: string) => {
|
||||
const container = getContainerForApp.value(appId)
|
||||
if (!container) return 'not-installed'
|
||||
return container.state
|
||||
})
|
||||
|
||||
// Get enriched bundled apps with runtime data (like lan_address)
|
||||
const enrichedBundledApps = computed(() => {
|
||||
return BUNDLED_APPS.map(app => {
|
||||
const container = getContainerForApp.value(app.id)
|
||||
return {
|
||||
...app,
|
||||
lan_address: container?.lan_address
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Actions
|
||||
async function fetchContainers() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
containers.value = await containerClient.listContainers()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to fetch containers'
|
||||
if (import.meta.env.DEV) console.error('Failed to fetch containers:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchHealthStatus() {
|
||||
try {
|
||||
healthStatus.value = await containerClient.getHealthStatus()
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.error('Failed to fetch health status:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function installApp(manifestPath: string) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const containerName = await containerClient.installApp(manifestPath)
|
||||
await fetchContainers()
|
||||
return containerName
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to install app'
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function startContainer(appId: string) {
|
||||
loadingApps.value.add(appId)
|
||||
error.value = null
|
||||
try {
|
||||
await containerClient.startContainer(appId)
|
||||
await fetchContainers()
|
||||
await fetchHealthStatus()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to start container'
|
||||
throw e
|
||||
} finally {
|
||||
loadingApps.value.delete(appId)
|
||||
}
|
||||
}
|
||||
|
||||
async function stopContainer(appId: string) {
|
||||
loadingApps.value.add(appId)
|
||||
error.value = null
|
||||
try {
|
||||
await containerClient.stopContainer(appId)
|
||||
await fetchContainers()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to stop container'
|
||||
throw e
|
||||
} finally {
|
||||
loadingApps.value.delete(appId)
|
||||
}
|
||||
}
|
||||
|
||||
// Start a bundled app (creates and starts container)
|
||||
async function startBundledApp(app: BundledApp) {
|
||||
loadingApps.value.add(app.id)
|
||||
error.value = null
|
||||
try {
|
||||
await containerClient.startBundledApp(app)
|
||||
await fetchContainers()
|
||||
await fetchHealthStatus()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to start app'
|
||||
throw e
|
||||
} finally {
|
||||
loadingApps.value.delete(app.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Stop a bundled app
|
||||
async function stopBundledApp(appId: string) {
|
||||
loadingApps.value.add(appId)
|
||||
error.value = null
|
||||
try {
|
||||
await containerClient.stopBundledApp(appId)
|
||||
await fetchContainers()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to stop app'
|
||||
throw e
|
||||
} finally {
|
||||
loadingApps.value.delete(appId)
|
||||
}
|
||||
}
|
||||
|
||||
async function removeContainer(appId: string) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await containerClient.removeContainer(appId)
|
||||
await fetchContainers()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to remove container'
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function getContainerLogs(appId: string, lines: number = 100) {
|
||||
try {
|
||||
return await containerClient.getContainerLogs(appId, lines)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to get logs'
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async function getContainerStatus(appId: string) {
|
||||
try {
|
||||
return await containerClient.getContainerStatus(appId)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to get status'
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
containers,
|
||||
healthStatus,
|
||||
loading,
|
||||
loadingApps,
|
||||
error,
|
||||
// Getters
|
||||
runningContainers,
|
||||
stoppedContainers,
|
||||
getContainerById,
|
||||
getHealthStatus,
|
||||
getContainerForApp,
|
||||
isAppLoading,
|
||||
getAppState,
|
||||
enrichedBundledApps,
|
||||
// Actions
|
||||
fetchContainers,
|
||||
fetchHealthStatus,
|
||||
installApp,
|
||||
startContainer,
|
||||
stopContainer,
|
||||
removeContainer,
|
||||
getContainerLogs,
|
||||
getContainerStatus,
|
||||
startBundledApp,
|
||||
stopBundledApp,
|
||||
}
|
||||
})
|
||||
23
neode-ui/src/stores/controller.ts
Normal file
23
neode-ui/src/stores/controller.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useControllerStore = defineStore('controller', () => {
|
||||
const isActive = ref(false)
|
||||
const gamepadCount = ref(0)
|
||||
|
||||
function setActive(active: boolean) {
|
||||
isActive.value = active
|
||||
}
|
||||
|
||||
function setGamepadCount(count: number) {
|
||||
gamepadCount.value = count
|
||||
isActive.value = count > 0
|
||||
}
|
||||
|
||||
return {
|
||||
isActive,
|
||||
gamepadCount,
|
||||
setActive,
|
||||
setGamepadCount,
|
||||
}
|
||||
})
|
||||
144
neode-ui/src/stores/goals.ts
Normal file
144
neode-ui/src/stores/goals.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { GoalProgress, GoalStatus } from '@/types/goals'
|
||||
import { GOALS } from '@/data/goals'
|
||||
import { useAppStore } from './app'
|
||||
|
||||
const STORAGE_KEY = 'archipelago-goal-progress'
|
||||
|
||||
/** App ID aliases — goal definitions use canonical IDs but the backend may register under variant names */
|
||||
const APP_ALIASES: Record<string, string[]> = {
|
||||
immich: ['immich-server', 'immich-app', 'immich_server'],
|
||||
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
|
||||
'bitcoin-knots': ['bitcoin', 'bitcoin-core'],
|
||||
}
|
||||
|
||||
function matchesAppId(pkgId: string, appId: string): boolean {
|
||||
if (pkgId === appId) return true
|
||||
const aliases = APP_ALIASES[appId]
|
||||
return aliases ? aliases.includes(pkgId) : false
|
||||
}
|
||||
|
||||
export const useGoalStore = defineStore('goals', () => {
|
||||
const progress = ref<Record<string, GoalProgress>>({})
|
||||
|
||||
function load() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw) progress.value = JSON.parse(raw)
|
||||
} catch {
|
||||
/* ignore corrupt data */
|
||||
}
|
||||
}
|
||||
|
||||
function save() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(progress.value))
|
||||
}
|
||||
|
||||
function getGoalStatus(goalId: string): GoalStatus {
|
||||
const goal = GOALS.find((g) => g.id === goalId)
|
||||
if (!goal) return 'not-started'
|
||||
|
||||
// Goals with no required apps use manual progress tracking
|
||||
if (goal.requiredApps.length === 0) {
|
||||
return progress.value[goalId]?.status || 'not-started'
|
||||
}
|
||||
|
||||
const appStore = useAppStore()
|
||||
const packages = appStore.packages
|
||||
|
||||
// Auto-sync install step completion from actual package state
|
||||
// This ensures steps tick when apps are installed outside the wizard
|
||||
let didSync = false
|
||||
for (const step of goal.steps) {
|
||||
if (step.appId && step.action === 'install') {
|
||||
const isInstalled = Object.keys(packages).some((pkgId) => matchesAppId(pkgId, step.appId!))
|
||||
if (isInstalled) {
|
||||
if (!progress.value[goalId]) {
|
||||
progress.value[goalId] = {
|
||||
goalId,
|
||||
status: 'in-progress',
|
||||
currentStepIndex: 0,
|
||||
completedSteps: [],
|
||||
startedAt: Date.now(),
|
||||
}
|
||||
didSync = true
|
||||
}
|
||||
if (!progress.value[goalId].completedSteps.includes(step.id)) {
|
||||
progress.value[goalId].completedSteps.push(step.id)
|
||||
didSync = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (didSync) save()
|
||||
|
||||
const allRunning = goal.requiredApps.every((appId) =>
|
||||
Object.entries(packages).some(
|
||||
([pkgId, pkg]) => matchesAppId(pkgId, appId) && pkg.state === 'running',
|
||||
),
|
||||
)
|
||||
if (allRunning) return 'completed'
|
||||
|
||||
const anyInstalled = goal.requiredApps.some((appId) =>
|
||||
Object.keys(packages).some((pkgId) => matchesAppId(pkgId, appId)),
|
||||
)
|
||||
if (anyInstalled || progress.value[goalId]) return 'in-progress'
|
||||
|
||||
return 'not-started'
|
||||
}
|
||||
|
||||
const goalStatuses = computed(() => {
|
||||
const statuses: Record<string, GoalStatus> = {}
|
||||
for (const goal of GOALS) {
|
||||
statuses[goal.id] = getGoalStatus(goal.id)
|
||||
}
|
||||
return statuses
|
||||
})
|
||||
|
||||
function startGoal(goalId: string) {
|
||||
progress.value[goalId] = {
|
||||
goalId,
|
||||
status: 'in-progress',
|
||||
currentStepIndex: 0,
|
||||
completedSteps: [],
|
||||
startedAt: Date.now(),
|
||||
}
|
||||
save()
|
||||
}
|
||||
|
||||
function completeStep(goalId: string, stepId: string) {
|
||||
const p = progress.value[goalId]
|
||||
if (!p) return
|
||||
|
||||
if (!p.completedSteps.includes(stepId)) {
|
||||
p.completedSteps.push(stepId)
|
||||
}
|
||||
|
||||
const goal = GOALS.find((g) => g.id === goalId)
|
||||
if (goal && p.completedSteps.length >= goal.steps.length) {
|
||||
p.status = 'completed'
|
||||
} else {
|
||||
p.currentStepIndex = Math.min(p.currentStepIndex + 1, (goal?.steps.length ?? 1) - 1)
|
||||
}
|
||||
|
||||
save()
|
||||
}
|
||||
|
||||
function resetGoal(goalId: string) {
|
||||
delete progress.value[goalId]
|
||||
save()
|
||||
}
|
||||
|
||||
// Load on store creation
|
||||
load()
|
||||
|
||||
return {
|
||||
progress,
|
||||
goalStatuses,
|
||||
getGoalStatus,
|
||||
startGoal,
|
||||
completeStep,
|
||||
resetGoal,
|
||||
}
|
||||
})
|
||||
32
neode-ui/src/stores/loginTransition.ts
Normal file
32
neode-ui/src/stores/loginTransition.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
/** Signals that we just logged in - Dashboard uses this for zoom + oomph */
|
||||
export const useLoginTransitionStore = defineStore('loginTransition', () => {
|
||||
const justLoggedIn = ref(false)
|
||||
/** Show empty welcome block until typing starts (hide static text) */
|
||||
const pendingWelcomeTyping = ref(false)
|
||||
/** Trigger welcome typing on Home - set true after dashboard animation finishes */
|
||||
const startWelcomeTyping = ref(false)
|
||||
|
||||
function setJustLoggedIn(value: boolean) {
|
||||
justLoggedIn.value = value
|
||||
}
|
||||
|
||||
function setPendingWelcomeTyping(value: boolean) {
|
||||
pendingWelcomeTyping.value = value
|
||||
}
|
||||
|
||||
function setStartWelcomeTyping(value: boolean) {
|
||||
startWelcomeTyping.value = value
|
||||
}
|
||||
|
||||
return {
|
||||
justLoggedIn,
|
||||
setJustLoggedIn,
|
||||
pendingWelcomeTyping,
|
||||
setPendingWelcomeTyping,
|
||||
startWelcomeTyping,
|
||||
setStartWelcomeTyping,
|
||||
}
|
||||
})
|
||||
189
neode-ui/src/stores/mesh.ts
Normal file
189
neode-ui/src/stores/mesh.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
// Pinia store for mesh networking state (Meshcore LoRa)
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
export interface MeshStatus {
|
||||
enabled: boolean
|
||||
device_type: string
|
||||
device_path: string | null
|
||||
device_connected: boolean
|
||||
firmware_version: string | null
|
||||
self_node_id: number | null
|
||||
self_advert_name: string | null
|
||||
peer_count: number
|
||||
channel_name: string
|
||||
messages_sent: number
|
||||
messages_received: number
|
||||
detected_devices?: string[]
|
||||
}
|
||||
|
||||
export interface MeshPeer {
|
||||
contact_id: number
|
||||
advert_name: string
|
||||
did: string | null
|
||||
pubkey_hex: string | null
|
||||
rssi: number | null
|
||||
snr: number | null
|
||||
last_heard: string
|
||||
hops: number
|
||||
}
|
||||
|
||||
export interface MeshChannel {
|
||||
index: number
|
||||
name: string
|
||||
has_secret: boolean
|
||||
}
|
||||
|
||||
export interface MeshMessage {
|
||||
id: number
|
||||
direction: 'sent' | 'received'
|
||||
peer_contact_id: number
|
||||
peer_name: string | null
|
||||
plaintext: string
|
||||
timestamp: string
|
||||
delivered: boolean
|
||||
encrypted: boolean
|
||||
}
|
||||
|
||||
export const useMeshStore = defineStore('mesh', () => {
|
||||
const status = ref<MeshStatus | null>(null)
|
||||
const peers = ref<MeshPeer[]>([])
|
||||
const messages = ref<MeshMessage[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const sending = ref(false)
|
||||
|
||||
// Track unread message counts per peer (contact_id -> count)
|
||||
const unreadCounts = ref<Record<number, number>>({})
|
||||
// Currently viewing chat for this contact_id (clears unread)
|
||||
const viewingChatId = ref<number | null>(null)
|
||||
// Total unread count for nav badge
|
||||
const totalUnread = computed(() =>
|
||||
Object.values(unreadCounts.value).reduce((a, b) => a + b, 0)
|
||||
)
|
||||
|
||||
async function fetchStatus() {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const res = await rpcClient.call<MeshStatus>({ method: 'mesh.status' })
|
||||
status.value = res
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch mesh status'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPeers() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ peers: MeshPeer[]; count: number }>({
|
||||
method: 'mesh.peers',
|
||||
})
|
||||
peers.value = res.peers
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch mesh peers'
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMessages(limit?: number) {
|
||||
try {
|
||||
const res = await rpcClient.call<{ messages: MeshMessage[]; count: number }>({
|
||||
method: 'mesh.messages',
|
||||
params: limit ? { limit } : {},
|
||||
})
|
||||
// Detect new incoming messages and increment unread counts
|
||||
const newMsgs = res.messages.filter(
|
||||
m => m.direction === 'received' && !messages.value.some(existing => existing.id === m.id)
|
||||
)
|
||||
for (const msg of newMsgs) {
|
||||
// Don't count as unread if we're currently viewing that chat
|
||||
if (msg.peer_contact_id !== viewingChatId.value) {
|
||||
unreadCounts.value[msg.peer_contact_id] = (unreadCounts.value[msg.peer_contact_id] || 0) + 1
|
||||
}
|
||||
}
|
||||
messages.value = res.messages
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch mesh messages'
|
||||
}
|
||||
}
|
||||
|
||||
function markChatRead(contactId: number) {
|
||||
viewingChatId.value = contactId
|
||||
delete unreadCounts.value[contactId]
|
||||
}
|
||||
|
||||
function clearViewingChat() {
|
||||
viewingChatId.value = null
|
||||
}
|
||||
|
||||
async function sendMessage(contactId: number, message: string) {
|
||||
try {
|
||||
sending.value = true
|
||||
error.value = null
|
||||
const res = await rpcClient.call<{ sent: boolean; message_id: number; encrypted: boolean }>({
|
||||
method: 'mesh.send',
|
||||
params: { contact_id: contactId, message: message.trim() },
|
||||
})
|
||||
// Refresh messages after sending
|
||||
if (res.sent) {
|
||||
await fetchMessages()
|
||||
}
|
||||
return res
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to send mesh message'
|
||||
throw err
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function broadcastIdentity() {
|
||||
try {
|
||||
error.value = null
|
||||
await rpcClient.call<{ broadcast: boolean }>({ method: 'mesh.broadcast' })
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to broadcast identity'
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function configure(config: Partial<MeshStatus>) {
|
||||
try {
|
||||
error.value = null
|
||||
await rpcClient.call<{ configured: boolean }>({
|
||||
method: 'mesh.configure',
|
||||
params: config,
|
||||
})
|
||||
await fetchStatus()
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to configure mesh'
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
await Promise.all([fetchStatus(), fetchPeers(), fetchMessages()])
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
peers,
|
||||
messages,
|
||||
loading,
|
||||
error,
|
||||
sending,
|
||||
unreadCounts,
|
||||
totalUnread,
|
||||
fetchStatus,
|
||||
fetchPeers,
|
||||
fetchMessages,
|
||||
sendMessage,
|
||||
broadcastIdentity,
|
||||
configure,
|
||||
refreshAll,
|
||||
markChatRead,
|
||||
clearViewingChat,
|
||||
}
|
||||
})
|
||||
42
neode-ui/src/stores/screensaver.ts
Normal file
42
neode-ui/src/stores/screensaver.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const INACTIVITY_MS = 3 * 60 * 1000 // 3 minutes
|
||||
|
||||
export const useScreensaverStore = defineStore('screensaver', () => {
|
||||
const isActive = ref(false)
|
||||
let inactivityTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function activate() {
|
||||
isActive.value = true
|
||||
clearInactivityTimer()
|
||||
}
|
||||
|
||||
function deactivate() {
|
||||
isActive.value = false
|
||||
resetInactivityTimer()
|
||||
}
|
||||
|
||||
function resetInactivityTimer() {
|
||||
clearInactivityTimer()
|
||||
inactivityTimer = setTimeout(() => {
|
||||
inactivityTimer = null
|
||||
isActive.value = true
|
||||
}, INACTIVITY_MS)
|
||||
}
|
||||
|
||||
function clearInactivityTimer() {
|
||||
if (inactivityTimer) {
|
||||
clearTimeout(inactivityTimer)
|
||||
inactivityTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isActive,
|
||||
activate,
|
||||
deactivate,
|
||||
resetInactivityTimer,
|
||||
clearInactivityTimer,
|
||||
}
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user