Update Fedimint configuration and enhance onboarding process
- Upgraded Fedimint version to v0.10.0 in docker-compose.yml and manifest.yml, adding support for the built-in Guardian UI. - Modified .gitignore to exclude deploy-config.sh script. - Enhanced onboarding process in AuthManager to persist onboarding state and validate password strength during user setup. - Updated API to handle onboarding completion and password change requests, ensuring a smoother user experience. - Improved configuration management to support Nostr discovery and Tor proxy settings, enhancing node identity features.
This commit is contained in:
@@ -6,18 +6,46 @@
|
||||
<!-- Main App Content - only show after splash and routing is complete -->
|
||||
<RouterView v-if="!showSplash && isReady" />
|
||||
|
||||
<!-- Spotlight command palette (Cmd+K / Ctrl+K) -->
|
||||
<SpotlightSearch />
|
||||
|
||||
<!-- 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 />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import SplashScreen from './components/SplashScreen.vue'
|
||||
import PWAUpdatePrompt from './components/PWAUpdatePrompt.vue'
|
||||
import SpotlightSearch from './components/SpotlightSearch.vue'
|
||||
import HelpGuideModal from './components/HelpGuideModal.vue'
|
||||
import { useControllerNav } from '@/composables/useControllerNav'
|
||||
import { useSpotlightStore } from '@/stores/spotlight'
|
||||
|
||||
const router = useRouter()
|
||||
const spotlightStore = useSpotlightStore()
|
||||
useControllerNav()
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
const isMac = navigator.platform.toUpperCase().includes('MAC')
|
||||
const mod = isMac ? e.metaKey : e.ctrlKey
|
||||
if (mod && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
spotlightStore.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const showSplash = ref(true)
|
||||
const isReady = ref(false)
|
||||
@@ -29,6 +57,7 @@ const isReady = ref(false)
|
||||
* - User is on a direct route (refresh/bookmark)
|
||||
*/
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
const seenIntro = localStorage.getItem('neode_intro_seen') === '1'
|
||||
const isDirectRoute = route.path !== '/'
|
||||
|
||||
@@ -43,48 +72,33 @@ onMounted(() => {
|
||||
// SplashScreen will emit 'complete' which calls handleSplashComplete
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
})
|
||||
|
||||
/**
|
||||
* Handle splash screen completion
|
||||
* Routes user directly to appropriate screen based on onboarding status
|
||||
* Routes user directly to appropriate screen based on onboarding status (from backend)
|
||||
*/
|
||||
function handleSplashComplete() {
|
||||
async function handleSplashComplete() {
|
||||
showSplash.value = false
|
||||
document.body.classList.add('splash-complete')
|
||||
|
||||
// Set isReady first so RouterView can render
|
||||
isReady.value = true
|
||||
|
||||
// Determine destination based on onboarding status and dev mode
|
||||
|
||||
const devMode = import.meta.env.VITE_DEV_MODE
|
||||
const seenOnboarding = localStorage.getItem('neode_onboarding_complete') === '1'
|
||||
// const isSetup = localStorage.getItem('neode_setup_complete') === '1'
|
||||
|
||||
let destination = '/'
|
||||
|
||||
// Setup mode: always go to login
|
||||
if (devMode === 'setup') {
|
||||
destination = '/login'
|
||||
if (devMode === 'setup' || devMode === 'existing') {
|
||||
router.push('/login').catch(() => {})
|
||||
return
|
||||
}
|
||||
// Onboarding mode: go to onboarding if not seen
|
||||
else if (devMode === 'onboarding') {
|
||||
destination = seenOnboarding ? '/login' : '/onboarding/intro'
|
||||
|
||||
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(() => {})
|
||||
}
|
||||
// Existing user mode: go to login
|
||||
else if (devMode === 'existing') {
|
||||
destination = '/login'
|
||||
}
|
||||
// Default: check onboarding status
|
||||
else {
|
||||
destination = seenOnboarding ? '/login' : '/onboarding/intro'
|
||||
}
|
||||
|
||||
// Route after a brief delay to ensure RouterView is mounted
|
||||
// The router's redirect will handle the actual navigation
|
||||
router.push(destination).catch(err => {
|
||||
console.error('Navigation error:', err)
|
||||
// Still show the app even if navigation fails
|
||||
isReady.value = true
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -77,6 +77,21 @@ class RPCClient {
|
||||
})
|
||||
}
|
||||
|
||||
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',
|
||||
@@ -84,6 +99,113 @@ class RPCClient {
|
||||
})
|
||||
}
|
||||
|
||||
async completeOnboarding(): Promise<boolean> {
|
||||
return this.call({
|
||||
method: 'auth.onboardingComplete',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async isOnboardingComplete(): Promise<boolean> {
|
||||
return this.call({
|
||||
method: 'auth.isOnboardingComplete',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async getNodeDid(): Promise<{ did: string; pubkey: string }> {
|
||||
return this.call({
|
||||
method: 'node.did',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async publishNostrIdentity(): Promise<{ event_id: string; success: number; failed: number }> {
|
||||
return this.call({
|
||||
method: 'node.nostr-publish',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async getNostrPubkey(): Promise<{ nostr_pubkey: 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 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',
|
||||
|
||||
109
neode-ui/src/components/AnimatedLogo.vue
Normal file
109
neode-ui/src/components/AnimatedLogo.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div class="logo-gradient-border flex-shrink-0 inline-block overflow-hidden">
|
||||
<!-- Neode logo - white or coloured -->
|
||||
<svg
|
||||
class="w-14 h-14 block logo-svg"
|
||||
viewBox="0 0 1024 1024"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="Neode"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient :id="gradientId" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#f9aa4b" />
|
||||
<stop offset="50%" stop-color="#f7931a" />
|
||||
<stop offset="100%" stop-color="#e68a19" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<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="mode === 'coloured' ? `url(#${gradientId})` : 'white'"
|
||||
class="logo-square"
|
||||
:class="{ 'logo-square-coloured': mode === 'coloured' }"
|
||||
:style="{ '--delay': delays[i] + 'ms' }"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
const gradientId = 'logo-color-' + Math.random().toString(36).slice(2, 11)
|
||||
|
||||
// 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) - spread over ~4.5s load phase
|
||||
const delays = [0, 2341, 467, 1890, 312, 3456, 123, 2789, 567, 4123, 901, 1456, 234, 3789, 2678, 456, 847, 2912, 1891, 423]
|
||||
|
||||
type Mode = 'normal' | 'coloured'
|
||||
const mode = ref<Mode>('normal')
|
||||
let cycleCount = 0
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const CYCLE_MS = 10000
|
||||
|
||||
onMounted(() => {
|
||||
intervalId = setInterval(() => {
|
||||
cycleCount++
|
||||
mode.value = cycleCount % 4 === 3 ? 'coloured' : 'normal'
|
||||
}, CYCLE_MS)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (intervalId) clearInterval(intervalId)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.logo-svg {
|
||||
will-change: auto;
|
||||
}
|
||||
|
||||
.logo-square {
|
||||
opacity: 0;
|
||||
animation: logo-square-in 10s cubic-bezier(0.4, 0, 0.2, 1) infinite;
|
||||
animation-delay: var(--delay, 0ms);
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
.logo-square-coloured {
|
||||
filter: drop-shadow(0 0 2px rgba(247, 147, 26, 0.4));
|
||||
}
|
||||
|
||||
/* 0–45%: squares load in. 45–100%: full logo visible */
|
||||
@keyframes logo-square-in {
|
||||
0% { opacity: 0; transform: scale(0.95); }
|
||||
4% { opacity: 1; transform: scale(1); }
|
||||
45% { opacity: 1; transform: scale(1); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
</style>
|
||||
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>
|
||||
58
neode-ui/src/components/HelpGuideModal.vue
Normal file
58
neode-ui/src/components/HelpGuideModal.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<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
|
||||
@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">
|
||||
defineProps<{
|
||||
show: boolean
|
||||
title: string
|
||||
content: string
|
||||
relatedPath?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
</script>
|
||||
361
neode-ui/src/components/SpotlightSearch.vue
Normal file
361
neode-ui/src/components/SpotlightSearch.vue
Normal file
@@ -0,0 +1,361 @@
|
||||
<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 { helpTree, flattenForSearch, type SearchableItem } from '@/data/helpTree'
|
||||
|
||||
const router = useRouter()
|
||||
const spotlightStore = useSpotlightStore()
|
||||
|
||||
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 searchableItems = flattenForSearch()
|
||||
const fuse = new Fuse(searchableItems, {
|
||||
keys: ['label', 'section'],
|
||||
threshold: 0.4,
|
||||
})
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
const q = query.value.trim()
|
||||
if (!q) return []
|
||||
const results = fuse.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 + searchableItems.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 {
|
||||
savedPosition.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function savePosition(x: number, y: number) {
|
||||
savedPosition.value = { x, y }
|
||||
try {
|
||||
localStorage.setItem(SAVED_POSITION_KEY, JSON.stringify({ x, y }))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function 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 selectItem(item: SearchableItem) {
|
||||
spotlightStore.addRecentItem({
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
path: item.path,
|
||||
type: item.type,
|
||||
})
|
||||
spotlightStore.close()
|
||||
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) {
|
||||
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' }) {
|
||||
spotlightStore.close()
|
||||
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>
|
||||
170
neode-ui/src/composables/useControllerNav.ts
Normal file
170
neode-ui/src/composables/useControllerNav.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Controller / gamepad-style navigation for Archipelago.
|
||||
* Supports Rii X8 (keyboard/d-pad) and standard gamepads.
|
||||
* - Arrow keys / d-pad: navigate between focusable elements
|
||||
* - Enter / A button: activate
|
||||
* - Escape / B button: back
|
||||
* - Game-like navigation sounds and visual feedback
|
||||
*/
|
||||
|
||||
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useControllerStore } from '@/stores/controller'
|
||||
import { useSpotlightStore } from '@/stores/spotlight'
|
||||
|
||||
const FOCUSABLE_SELECTOR = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
'input:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
'[data-controller-focus]',
|
||||
].join(', ')
|
||||
|
||||
function getFocusableElements(container: Document | HTMLElement = document): HTMLElement[] {
|
||||
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
|
||||
(el) => !el.hasAttribute('disabled') && el.offsetParent !== null
|
||||
)
|
||||
}
|
||||
|
||||
function playNavSound(type: 'move' | 'select' | 'back' = 'move') {
|
||||
try {
|
||||
const ctx = new (window.AudioContext || (window as any).webkitAudioContext)()
|
||||
const osc = ctx.createOscillator()
|
||||
const gain = ctx.createGain()
|
||||
osc.connect(gain)
|
||||
gain.connect(ctx.destination)
|
||||
gain.gain.value = 0.08
|
||||
osc.frequency.value = type === 'select' ? 880 : type === 'back' ? 220 : 440
|
||||
osc.type = 'sine'
|
||||
osc.start()
|
||||
osc.stop(ctx.currentTime + 0.05)
|
||||
} catch {
|
||||
// Audio not supported or blocked
|
||||
}
|
||||
}
|
||||
|
||||
export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
||||
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
|
||||
|
||||
// Ignore when typing in inputs
|
||||
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)
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
if (useSpotlightStore().isOpen) {
|
||||
useSpotlightStore().close()
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return
|
||||
}
|
||||
playNavSound('back')
|
||||
window.history.back()
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
if (currentIndex >= 0 && focusable[currentIndex]) {
|
||||
playNavSound('select')
|
||||
;(focusable[currentIndex] as HTMLElement).click()
|
||||
}
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
||||
isControllerActive.value = true
|
||||
if (keyNavTimeout) clearTimeout(keyNavTimeout)
|
||||
keyNavTimeout = setTimeout(() => {
|
||||
isControllerActive.value = gamepadCount.value > 0
|
||||
}, 3000)
|
||||
|
||||
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' })
|
||||
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
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkGamepads()
|
||||
window.addEventListener('keydown', handleKeyDown, true)
|
||||
window.addEventListener('gamepadconnected', handleGamepadConnected)
|
||||
window.addEventListener('gamepaddisconnected', handleGamepadDisconnected)
|
||||
pollIntervalId = setInterval(handleGamepadInput, 500)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', handleKeyDown, true)
|
||||
window.removeEventListener('gamepadconnected', handleGamepadConnected)
|
||||
window.removeEventListener('gamepaddisconnected', handleGamepadDisconnected)
|
||||
if (pollIntervalId) clearInterval(pollIntervalId)
|
||||
if (keyNavTimeout) clearTimeout(keyNavTimeout)
|
||||
})
|
||||
|
||||
return {
|
||||
isControllerActive,
|
||||
gamepadCount,
|
||||
}
|
||||
}
|
||||
86
neode-ui/src/composables/useMessageToast.ts
Normal file
86
neode-ui/src/composables/useMessageToast.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
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`,
|
||||
}
|
||||
} else {
|
||||
lastMessageCount.value = msgs.length
|
||||
}
|
||||
} catch (e) {
|
||||
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,
|
||||
}
|
||||
}
|
||||
20
neode-ui/src/composables/useOnboarding.ts
Normal file
20
neode-ui/src/composables/useOnboarding.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Onboarding state - prefers backend, falls back to localStorage for mock/offline.
|
||||
*/
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
export async function isOnboardingComplete(): Promise<boolean> {
|
||||
try {
|
||||
return await rpcClient.isOnboardingComplete()
|
||||
} catch {
|
||||
return localStorage.getItem('neode_onboarding_complete') === '1'
|
||||
}
|
||||
}
|
||||
|
||||
export async function completeOnboarding(): Promise<void> {
|
||||
try {
|
||||
await rpcClient.completeOnboarding()
|
||||
} finally {
|
||||
localStorage.setItem('neode_onboarding_complete', '1')
|
||||
}
|
||||
}
|
||||
97
neode-ui/src/data/helpTree.ts
Normal file
97
neode-ui/src/data/helpTree.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
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'
|
||||
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: 'bitcoin-basics',
|
||||
label: 'Bitcoin Basics',
|
||||
content: 'Bitcoin is a decentralized digital currency. Your node validates transactions and maintains the blockchain locally.',
|
||||
relatedPath: '/dashboard/server',
|
||||
},
|
||||
{
|
||||
id: 'lightning-network',
|
||||
label: 'Lightning Network',
|
||||
content: 'Lightning enables instant, low-fee payments. Open channels with other nodes to send and receive payments off-chain.',
|
||||
relatedPath: '/dashboard/apps',
|
||||
},
|
||||
{
|
||||
id: 'self-hosting',
|
||||
label: 'Self-Hosting',
|
||||
content: 'Archipelago runs your services locally. Your data stays on your hardware, giving you full control and privacy.',
|
||||
relatedPath: '/dashboard',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
label: 'Actions',
|
||||
items: [
|
||||
{ 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' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function flattenForSearch(): SearchableItem[] {
|
||||
const result: SearchableItem[] = []
|
||||
for (const section of helpTree) {
|
||||
const type =
|
||||
section.id === 'navigate'
|
||||
? 'navigate'
|
||||
: section.id === 'learn'
|
||||
? 'learn'
|
||||
: '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
|
||||
}
|
||||
@@ -11,30 +11,7 @@ const router = createRouter({
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirect: (_to) => {
|
||||
// Initial routing logic - determines first screen after splash
|
||||
const devMode = import.meta.env.VITE_DEV_MODE
|
||||
const seenOnboarding = localStorage.getItem('neode_onboarding_complete') === '1'
|
||||
// const isSetup = localStorage.getItem('neode_setup_complete') === '1'
|
||||
|
||||
// Setup mode: go directly to login (original StartOS setup)
|
||||
if (devMode === 'setup') {
|
||||
return '/login'
|
||||
}
|
||||
|
||||
// Onboarding mode: go to experimental onboarding flow
|
||||
if (devMode === 'onboarding') {
|
||||
return seenOnboarding ? '/login' : '/onboarding/intro'
|
||||
}
|
||||
|
||||
// Existing user mode: go to login
|
||||
if (devMode === 'existing') {
|
||||
return '/login'
|
||||
}
|
||||
|
||||
// Default: check if user has completed onboarding
|
||||
return seenOnboarding ? '/login' : '/onboarding/intro'
|
||||
},
|
||||
component: () => import('../views/RootRedirect.vue'),
|
||||
},
|
||||
{
|
||||
path: 'login',
|
||||
|
||||
@@ -164,6 +164,7 @@ export const useAppStore = defineStore('app', () => {
|
||||
'update-progress': null,
|
||||
},
|
||||
'lan-address': null,
|
||||
'tor-address': null,
|
||||
unread: 0,
|
||||
'wifi-ssids': [],
|
||||
'zram-enabled': false,
|
||||
|
||||
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,
|
||||
}
|
||||
})
|
||||
99
neode-ui/src/stores/spotlight.ts
Normal file
99
neode-ui/src/stores/spotlight.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, reactive } from 'vue'
|
||||
|
||||
const RECENT_ITEMS_KEY = 'archipelago-spotlight-recent'
|
||||
const MAX_RECENT_ITEMS = 8
|
||||
|
||||
export interface RecentItem {
|
||||
id: string
|
||||
label: string
|
||||
path?: string
|
||||
type: 'navigate' | 'learn' | 'action'
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export const useSpotlightStore = defineStore('spotlight', () => {
|
||||
const isOpen = ref(false)
|
||||
const selectedIndex = ref(0)
|
||||
|
||||
const recentItems = ref<RecentItem[]>([])
|
||||
|
||||
function loadRecentItems() {
|
||||
try {
|
||||
const raw = localStorage.getItem(RECENT_ITEMS_KEY)
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as RecentItem[]
|
||||
recentItems.value = parsed.slice(0, MAX_RECENT_ITEMS)
|
||||
} else {
|
||||
recentItems.value = []
|
||||
}
|
||||
} catch {
|
||||
recentItems.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function addRecentItem(item: Omit<RecentItem, 'timestamp'>) {
|
||||
const withTimestamp: RecentItem = { ...item, timestamp: Date.now() }
|
||||
const filtered = recentItems.value.filter(
|
||||
(r) => !(r.id === item.id && r.type === item.type)
|
||||
)
|
||||
recentItems.value = [withTimestamp, ...filtered].slice(0, MAX_RECENT_ITEMS)
|
||||
try {
|
||||
localStorage.setItem(RECENT_ITEMS_KEY, JSON.stringify(recentItems.value))
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
function open() {
|
||||
isOpen.value = true
|
||||
selectedIndex.value = 0
|
||||
loadRecentItems()
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen.value = false
|
||||
selectedIndex.value = 0
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
isOpen.value ? close() : open()
|
||||
}
|
||||
|
||||
function setSelectedIndex(index: number) {
|
||||
selectedIndex.value = index
|
||||
}
|
||||
|
||||
const helpModal = reactive({
|
||||
show: false,
|
||||
title: '',
|
||||
content: '',
|
||||
relatedPath: undefined as string | undefined,
|
||||
})
|
||||
|
||||
function showHelpModal(payload: { title: string; content: string; relatedPath?: string }) {
|
||||
helpModal.show = true
|
||||
helpModal.title = payload.title
|
||||
helpModal.content = payload.content
|
||||
helpModal.relatedPath = payload.relatedPath
|
||||
}
|
||||
|
||||
function closeHelpModal() {
|
||||
helpModal.show = false
|
||||
}
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
selectedIndex,
|
||||
recentItems,
|
||||
open,
|
||||
close,
|
||||
toggle,
|
||||
setSelectedIndex,
|
||||
addRecentItem,
|
||||
loadRecentItems,
|
||||
helpModal,
|
||||
showHelpModal,
|
||||
closeHelpModal,
|
||||
}
|
||||
})
|
||||
@@ -2,6 +2,12 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Controller / keyboard navigation - game-like focus ring */
|
||||
*:focus-visible {
|
||||
outline: 2px solid rgba(251, 191, 36, 0.8);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Global glassmorphism utilities */
|
||||
@layer components {
|
||||
.glass {
|
||||
@@ -32,6 +38,13 @@
|
||||
}
|
||||
|
||||
.glass-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 48px;
|
||||
min-height: 48px;
|
||||
padding-block: 0 !important;
|
||||
line-height: 48px;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
@@ -39,6 +52,34 @@
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.glass-button-sm {
|
||||
min-height: 0 !important;
|
||||
height: auto !important;
|
||||
line-height: inherit;
|
||||
padding-block: 0.375rem !important;
|
||||
padding-inline: 0.75rem;
|
||||
}
|
||||
|
||||
/* Toast - glassmorphic, top-right */
|
||||
.toast-glass {
|
||||
background-color: rgba(0, 0, 0, 0.65);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
/* Toast transition */
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.toast-enter-from,
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(1rem);
|
||||
}
|
||||
|
||||
/* Gradient containers - transparent to black */
|
||||
.gradient-card {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(0, 0, 0, 0.8) 100%);
|
||||
@@ -91,7 +132,8 @@
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.logo-gradient-border img {
|
||||
.logo-gradient-border img,
|
||||
.logo-gradient-border svg {
|
||||
border-radius: 9999px;
|
||||
display: block;
|
||||
position: relative;
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface ServerInfo {
|
||||
pubkey: string
|
||||
'status-info': StatusInfo
|
||||
'lan-address': string | null
|
||||
'tor-address': string | null
|
||||
'node-address'?: string
|
||||
unread: number
|
||||
'wifi-ssids': string[]
|
||||
'zram-enabled': boolean
|
||||
@@ -48,6 +50,7 @@ export const PackageState = {
|
||||
Installed: 'installed',
|
||||
Stopping: 'stopping',
|
||||
Stopped: 'stopped',
|
||||
Exited: 'exited',
|
||||
Starting: 'starting',
|
||||
Running: 'running',
|
||||
Restarting: 'restarting',
|
||||
|
||||
@@ -191,7 +191,7 @@ export const dummyApps: Record<string, PackageDataEntry> = {
|
||||
'static-files': {
|
||||
license: 'MIT',
|
||||
instructions: 'Federated Bitcoin mint',
|
||||
icon: '/assets/img/icon-fedimint.jpeg'
|
||||
icon: '/assets/img/app-icons/fedimint.png'
|
||||
},
|
||||
manifest: {
|
||||
id: 'fedimint',
|
||||
@@ -216,7 +216,7 @@ export const dummyApps: Record<string, PackageDataEntry> = {
|
||||
'interface-addresses': {
|
||||
main: {
|
||||
'tor-address': 'fedimint.onion',
|
||||
'lan-address': 'http://localhost:8173'
|
||||
'lan-address': 'http://localhost:8175'
|
||||
}
|
||||
},
|
||||
status: ServiceStatus.Running
|
||||
|
||||
@@ -268,6 +268,39 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Access (LAN + Tor) Card -->
|
||||
<div v-if="interfaceAddresses" class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">Access</h3>
|
||||
<div class="space-y-3">
|
||||
<div v-if="interfaceAddresses['lan-address']" class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-green-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-white/80 font-medium">LAN</p>
|
||||
<a
|
||||
:href="lanUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-blue-400 hover:text-blue-300 text-sm break-all"
|
||||
>
|
||||
{{ interfaceAddresses['lan-address'] }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isRealOnionAddress(interfaceAddresses['tor-address'])" class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-amber-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-white/80 font-medium">Tor</p>
|
||||
<span class="text-amber-300/90 text-sm font-mono break-all">{{ torUrl }}</span>
|
||||
<p class="text-white/50 text-xs mt-1">Requires Tor Browser</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Requirements Card -->
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">Requirements</h3>
|
||||
@@ -407,17 +440,69 @@ const route = useRoute()
|
||||
const store = useAppStore()
|
||||
|
||||
const appId = computed(() => route.params.id as string)
|
||||
// Check both store.packages and dummyApps
|
||||
|
||||
/** Map route/marketplace app IDs to backend package keys (container names). */
|
||||
const ROUTE_TO_PACKAGE_KEY: Record<string, string> = {
|
||||
mempool: 'mempool-web',
|
||||
'mempool-electrs': 'mempool-electrs',
|
||||
electrs: 'mempool-electrs',
|
||||
btcpay: 'btcpay-server',
|
||||
'btcpay-server': 'btcpay-server',
|
||||
fedimint: 'fedimint',
|
||||
lnd: 'lnd',
|
||||
'lnd-ui': 'lnd',
|
||||
bitcoin: 'bitcoin-knots',
|
||||
'bitcoin-knots': 'bitcoin-knots',
|
||||
}
|
||||
|
||||
function resolvePackageKey(routeId: string): string {
|
||||
return ROUTE_TO_PACKAGE_KEY[routeId] ?? routeId
|
||||
}
|
||||
|
||||
// Check both store.packages and dummyApps; resolve route ID to package key for backend data
|
||||
const pkg = computed(() => {
|
||||
// First check real packages
|
||||
if (store.packages[appId.value]) {
|
||||
return store.packages[appId.value]
|
||||
const routeId = appId.value
|
||||
const packageKey = resolvePackageKey(routeId)
|
||||
// First check real packages (try both route id and resolved key)
|
||||
if (store.packages[packageKey]) {
|
||||
return store.packages[packageKey]
|
||||
}
|
||||
if (store.packages[routeId]) {
|
||||
return store.packages[routeId]
|
||||
}
|
||||
// Fall back to dummy apps
|
||||
if (dummyApps[appId.value]) {
|
||||
return dummyApps[appId.value]
|
||||
if (dummyApps[routeId]) {
|
||||
return dummyApps[routeId]
|
||||
}
|
||||
return undefined
|
||||
return null
|
||||
})
|
||||
|
||||
const interfaceAddresses = computed(() => {
|
||||
const main = pkg.value?.installed?.['interface-addresses']?.main
|
||||
if (!main) return null
|
||||
if (!main['lan-address'] && !isRealOnionAddress(main['tor-address'])) return null
|
||||
return main
|
||||
})
|
||||
|
||||
/** V3 onion addresses are 56+ chars + .onion. Placeholders like "btcpay.onion" are not real. */
|
||||
function isRealOnionAddress(addr: string | undefined): boolean {
|
||||
return !!(addr && addr.endsWith('.onion') && addr.length >= 60 && addr.length <= 70)
|
||||
}
|
||||
|
||||
const lanUrl = computed(() => {
|
||||
const addr = interfaceAddresses.value?.['lan-address']
|
||||
if (!addr) return '#'
|
||||
if (addr.includes('localhost')) {
|
||||
return addr.replace('localhost', window.location.hostname)
|
||||
}
|
||||
return addr
|
||||
})
|
||||
|
||||
/** Tor URL with http:// prefix for copy-paste into Tor Browser */
|
||||
const torUrl = computed(() => {
|
||||
const addr = interfaceAddresses.value?.['tor-address']
|
||||
if (!addr || !isRealOnionAddress(addr)) return ''
|
||||
return addr.startsWith('http') ? addr : `http://${addr}`
|
||||
})
|
||||
|
||||
const uninstallModal = ref({
|
||||
@@ -543,8 +628,8 @@ function launchApp() {
|
||||
prod: 'http://localhost:8080'
|
||||
},
|
||||
'fedimint': {
|
||||
dev: 'http://localhost:8173',
|
||||
prod: 'http://localhost:8173'
|
||||
dev: 'http://localhost:8175',
|
||||
prod: 'http://192.168.1.228:8175'
|
||||
},
|
||||
'morphos-server': {
|
||||
dev: 'http://localhost:8081',
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<div
|
||||
v-for="[id, pkg] in sortedPackageEntries"
|
||||
:key="id"
|
||||
class="glass-card p-6 transition-all hover:-translate-y-1 cursor-pointer relative"
|
||||
class="glass-card p-6 transition-all hover:-translate-y-1 cursor-pointer relative min-w-0 overflow-hidden"
|
||||
@click="goToApp(id as string)"
|
||||
>
|
||||
<!-- Uninstall Icon -->
|
||||
@@ -48,8 +48,8 @@
|
||||
class="w-16 h-16 rounded-lg object-cover bg-white/10"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-white mb-1 truncate">
|
||||
<div class="flex-1 min-w-0 overflow-hidden">
|
||||
<h3 class="text-lg font-semibold text-white mb-1 truncate" :title="pkg.manifest.title">
|
||||
{{ pkg.manifest.title }}
|
||||
</h3>
|
||||
<p class="text-sm text-white/70 mb-2 truncate">
|
||||
|
||||
@@ -44,11 +44,9 @@
|
||||
<aside class="hidden md:flex w-[256px] border-r border-glass-border shadow-glass-sm flex-shrink-0 relative flex-col" style="background: rgba(0, 0, 0, 0.25); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px);">
|
||||
<div class="p-6 flex-1">
|
||||
<div class="flex items-center gap-3 mb-8">
|
||||
<div class="logo-gradient-border flex-shrink-0">
|
||||
<img src="/assets/icon/favico-black.svg" alt="Neode" class="w-14 h-14" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">{{ serverName }}</h2>
|
||||
<AnimatedLogo />
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="text-lg font-semibold text-white truncate">{{ serverName }}</h2>
|
||||
<p class="text-xs text-white/60">v{{ version }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -76,6 +74,11 @@
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Controller indicator - Desktop sidebar -->
|
||||
<div class="px-6 pb-2">
|
||||
<ControllerIndicator />
|
||||
</div>
|
||||
|
||||
<!-- User Section - Desktop Only -->
|
||||
<div class="p-6">
|
||||
<button
|
||||
@@ -114,6 +117,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New message toast (top right, glassmorphic) -->
|
||||
<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>
|
||||
|
||||
<!-- Persistent Mobile Tabs for Apps/Marketplace -->
|
||||
<div
|
||||
v-if="showAppsTabs"
|
||||
@@ -204,12 +231,11 @@
|
||||
<div :key="route.path" class="view-wrapper">
|
||||
<div
|
||||
:class="[
|
||||
'px-4 pt-4 md:px-8 md:pt-8 overflow-y-auto h-full',
|
||||
'px-4 pt-4 pb-4 md:px-8 md:pt-8 md:pb-8 overflow-y-auto h-full',
|
||||
needsMobileBackButtonSpace
|
||||
? 'pb-[calc(var(--mobile-tab-bar-height,_72px)+96px)]'
|
||||
? 'pb-[calc(var(--mobile-tab-bar-height,_72px)+96px)] md:pb-8'
|
||||
: undefined
|
||||
]"
|
||||
:style="contentPaddingBottomStyle"
|
||||
>
|
||||
<component :is="Component" class="view-container" />
|
||||
</div>
|
||||
@@ -262,8 +288,13 @@
|
||||
import { computed, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { RouterLink, RouterView, useRouter, useRoute } from 'vue-router'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { useMessageToast } from '@/composables/useMessageToast'
|
||||
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
||||
import ControllerIndicator from '@/components/ControllerIndicator.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const messageToast = useMessageToast()
|
||||
const toastMessage = messageToast.toastMessage
|
||||
const route = useRoute()
|
||||
const store = useAppStore()
|
||||
|
||||
@@ -437,9 +468,6 @@ watch(() => route.path, (newPath) => {
|
||||
|
||||
const needsMobileBackButtonSpace = computed(() => isDetailRoute(route.path))
|
||||
|
||||
const contentPaddingBottomStyle = computed(() =>
|
||||
(typeof window !== 'undefined' && window.innerWidth >= 768) ? { paddingBottom: '0' } : undefined
|
||||
)
|
||||
|
||||
// Show persistent tabs for Apps/Marketplace on mobile
|
||||
const showAppsTabs = computed(() => {
|
||||
@@ -501,6 +529,7 @@ function updateNetworkTabIndicator() {
|
||||
|
||||
onMounted(() => {
|
||||
updateTabBarHeight()
|
||||
messageToast.startPolling()
|
||||
updateAppsTabIndicator()
|
||||
updateNetworkTabIndicator()
|
||||
|
||||
@@ -513,6 +542,7 @@ onMounted(() => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', updateTabBarHeight)
|
||||
messageToast.stopPolling()
|
||||
})
|
||||
|
||||
// Watch route changes to update indicator position
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
<!-- Section Overviews -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<!-- My Apps Overview -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="glass-card p-6 flex flex-col h-full min-h-0">
|
||||
<div class="flex items-start justify-between mb-4 shrink-0">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white mb-1">My Apps</h2>
|
||||
<p class="text-sm text-white/70">Manage your installed applications</p>
|
||||
@@ -34,7 +34,7 @@
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<div class="grid grid-cols-2 gap-4 mb-4 flex-1 min-h-0">
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<p class="text-xs text-white/60 mb-1">Installed</p>
|
||||
<p class="text-2xl font-bold text-white">{{ appCount }}</p>
|
||||
@@ -45,7 +45,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex gap-2 mt-auto pt-4 shrink-0">
|
||||
<RouterLink
|
||||
to="/dashboard/marketplace"
|
||||
class="flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors"
|
||||
@@ -62,8 +62,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Cloud Overview -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="glass-card p-6 flex flex-col h-full min-h-0">
|
||||
<div class="flex items-start justify-between mb-4 shrink-0">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white mb-1">Cloud</h2>
|
||||
<p class="text-sm text-white/70">Cloud services and storage</p>
|
||||
@@ -78,7 +78,7 @@
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<div class="grid grid-cols-2 gap-4 mb-4 flex-1 min-h-0">
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<p class="text-xs text-white/60 mb-1">Storage Used</p>
|
||||
<p class="text-2xl font-bold text-white">2.4 GB</p>
|
||||
@@ -89,7 +89,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex gap-2 mt-auto pt-4 shrink-0">
|
||||
<RouterLink
|
||||
to="/dashboard/cloud"
|
||||
class="flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors"
|
||||
@@ -106,8 +106,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Network Overview -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="glass-card p-6 flex flex-col h-full min-h-0">
|
||||
<div class="flex items-start justify-between mb-4 shrink-0">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white mb-1">Network</h2>
|
||||
<p class="text-sm text-white/70">Network infrastructure and Web3 services</p>
|
||||
@@ -122,7 +122,7 @@
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 mb-4">
|
||||
<div class="space-y-3 mb-4 flex-1 min-h-0">
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2 h-2 rounded-full bg-green-400"></div>
|
||||
@@ -146,7 +146,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex gap-2 mt-auto pt-4 shrink-0">
|
||||
<RouterLink
|
||||
to="/dashboard/server"
|
||||
class="flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors"
|
||||
@@ -165,8 +165,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Web5 Overview -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="glass-card p-6 flex flex-col h-full min-h-0">
|
||||
<div class="flex items-start justify-between mb-4 shrink-0">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white mb-1">Web5</h2>
|
||||
<p class="text-sm text-white/70">Decentralized identity and data protocols</p>
|
||||
@@ -181,7 +181,7 @@
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 mb-4">
|
||||
<div class="space-y-3 mb-4 flex-1 min-h-0">
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2 h-2 rounded-full bg-green-400"></div>
|
||||
@@ -205,7 +205,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex gap-2 mt-auto pt-4 shrink-0">
|
||||
<RouterLink
|
||||
to="/dashboard/web5"
|
||||
class="flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors"
|
||||
|
||||
@@ -91,26 +91,33 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Category Tabs (Desktop only) -->
|
||||
<div class="hidden md:flex mb-6 glass-card p-2 rounded-lg flex-wrap gap-2">
|
||||
<button
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
@click="selectedCategory = category.id"
|
||||
:class="[
|
||||
'px-6 py-2 rounded-lg font-medium transition-all',
|
||||
selectedCategory === category.id
|
||||
? 'bg-white/20 text-white'
|
||||
: 'text-white/60 hover:text-white/80'
|
||||
]"
|
||||
>
|
||||
{{ category.name }}
|
||||
</button>
|
||||
<!-- Category Tabs + Search (Desktop only) -->
|
||||
<div class="hidden md:flex mb-6 glass-card p-2 rounded-lg items-center justify-between gap-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="category in categoriesWithApps"
|
||||
:key="category.id"
|
||||
@click="selectedCategory = category.id"
|
||||
:class="[
|
||||
'px-6 py-2 rounded-lg font-medium transition-all',
|
||||
selectedCategory === category.id
|
||||
? 'bg-white/20 text-white'
|
||||
: 'text-white/60 hover:text-white/80'
|
||||
]"
|
||||
>
|
||||
{{ category.name }}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search apps..."
|
||||
class="flex-shrink-0 w-64 px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="mb-6 pt-0 md:pt-0">
|
||||
<!-- Search Bar (Mobile - placeholder for later) -->
|
||||
<div class="md:hidden mb-6">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
@@ -297,7 +304,7 @@
|
||||
<!-- Category Grid -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
v-for="category in categories"
|
||||
v-for="category in categoriesWithApps"
|
||||
:key="category.id"
|
||||
@click="selectedCategory = category.id; showFilterModal = false"
|
||||
:class="[
|
||||
@@ -521,6 +528,15 @@ const allApps = computed(() => {
|
||||
return [...local, ...community]
|
||||
})
|
||||
|
||||
// Only show categories that have at least one app
|
||||
const categoriesWithApps = computed(() => {
|
||||
const apps = allApps.value
|
||||
return categories.filter(cat => {
|
||||
if (cat.id === 'all') return apps.length > 0
|
||||
return apps.some(app => app.category === cat.id)
|
||||
})
|
||||
})
|
||||
|
||||
// Filtered apps by category and search
|
||||
const filteredApps = computed(() => {
|
||||
let apps = allApps.value
|
||||
@@ -801,11 +817,11 @@ function getCuratedAppList() {
|
||||
{
|
||||
id: 'fedimint',
|
||||
title: 'Fedimint',
|
||||
version: '0.3.0',
|
||||
description: 'Federated Bitcoin mint. Private, scalable Bitcoin through federated guardians.',
|
||||
icon: '/assets/img/icon-fedimint.jpeg',
|
||||
version: '0.10.0',
|
||||
description: 'Federated Bitcoin mint with built-in Guardian UI. Private, scalable Bitcoin through federated guardians.',
|
||||
icon: '/assets/img/app-icons/fedimint.png',
|
||||
author: 'Fedimint',
|
||||
dockerImage: 'docker.io/fedimint/fedimintd:v0.3.0',
|
||||
dockerImage: 'docker.io/fedimint/fedimintd:v0.10.0',
|
||||
manifestUrl: null,
|
||||
repoUrl: 'https://github.com/fedimint/fedimint'
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="flex flex-col items-center gap-6 mb-6">
|
||||
<!-- Error message -->
|
||||
<p v-if="errorMessage" class="text-red-400 text-sm mb-4">{{ errorMessage }}</p>
|
||||
<!-- Generate Button (if no DID yet) -->
|
||||
<button
|
||||
v-if="!generatedDid"
|
||||
@@ -75,7 +77,8 @@
|
||||
<button
|
||||
v-if="generatedDid"
|
||||
@click="proceed"
|
||||
class="path-action-button path-action-button--continue"
|
||||
:disabled="generatedDid.includes('...')"
|
||||
class="path-action-button path-action-button--continue disabled:opacity-50"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
@@ -87,49 +90,40 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
const router = useRouter()
|
||||
const generatedDid = ref<string>('')
|
||||
const isGenerating = ref(false)
|
||||
const errorMessage = ref<string>('')
|
||||
|
||||
async function generateDid() {
|
||||
isGenerating.value = true
|
||||
|
||||
// Simulate DID generation (replace with actual implementation)
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
|
||||
// Mock DID generation - in production, this would call the backend
|
||||
const mockDid = `did:key:z6Mk${generateRandomString(44)}`
|
||||
generatedDid.value = mockDid
|
||||
|
||||
// Store in localStorage
|
||||
localStorage.setItem('neode_did', mockDid)
|
||||
|
||||
isGenerating.value = false
|
||||
}
|
||||
errorMessage.value = ''
|
||||
|
||||
function generateRandomString(length: number): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
let result = ''
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
try {
|
||||
const { did, pubkey } = await rpcClient.getNodeDid()
|
||||
generatedDid.value = did
|
||||
localStorage.setItem('neode_did', did)
|
||||
localStorage.setItem('neode_did_state', JSON.stringify({ did, kid: pubkey }))
|
||||
} catch (err) {
|
||||
errorMessage.value = err instanceof Error ? err.message : 'Failed to load node identity'
|
||||
// Fallback: show placeholder if backend unavailable (e.g. mock mode)
|
||||
if (!generatedDid.value) {
|
||||
generatedDid.value = 'did:key:z6Mk... (connect to server)'
|
||||
}
|
||||
} finally {
|
||||
isGenerating.value = false
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function proceed() {
|
||||
// Store DID state and continue to backup
|
||||
if (generatedDid.value) {
|
||||
localStorage.setItem('neode_did_state', JSON.stringify({
|
||||
did: generatedDid.value,
|
||||
kid: 'kid:mock'
|
||||
}))
|
||||
if (generatedDid.value && !generatedDid.value.includes('...')) {
|
||||
router.push('/onboarding/backup')
|
||||
}
|
||||
}
|
||||
|
||||
function skipForNow() {
|
||||
// Skip to backup screen
|
||||
router.push('/onboarding/backup')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { completeOnboarding } from '@/composables/useOnboarding'
|
||||
|
||||
const router = useRouter()
|
||||
const selected = ref<string | null>(null)
|
||||
@@ -96,13 +97,9 @@ function selectOption(option: string) {
|
||||
selected.value = option
|
||||
}
|
||||
|
||||
function proceed() {
|
||||
async function proceed() {
|
||||
if (selected.value) {
|
||||
// Mark onboarding as complete
|
||||
localStorage.setItem('neode_onboarding_complete', '1')
|
||||
|
||||
// For now, just go to login
|
||||
// In a real app, you'd have different flows for each option
|
||||
await completeOnboarding()
|
||||
router.push('/login')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { completeOnboarding } from '@/composables/useOnboarding'
|
||||
|
||||
const router = useRouter()
|
||||
const verified = ref(false)
|
||||
@@ -91,15 +92,14 @@ const signature = ref('')
|
||||
|
||||
async function signChallenge() {
|
||||
isSigning.value = true
|
||||
|
||||
|
||||
// Simulate signing challenge
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
|
||||
// Mock signature generation
|
||||
|
||||
const mockSignature = generateMockSignature()
|
||||
signature.value = mockSignature
|
||||
verified.value = true
|
||||
|
||||
|
||||
isSigning.value = false
|
||||
}
|
||||
|
||||
@@ -112,16 +112,14 @@ function generateMockSignature(): string {
|
||||
return result
|
||||
}
|
||||
|
||||
function proceed() {
|
||||
// Mark onboarding as complete
|
||||
localStorage.setItem('neode_onboarding_complete', '1')
|
||||
router.push('/onboarding/done')
|
||||
async function proceed() {
|
||||
await completeOnboarding()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
function skipForNow() {
|
||||
// Mark onboarding as complete
|
||||
localStorage.setItem('neode_onboarding_complete', '1')
|
||||
router.push('/onboarding/done')
|
||||
async function skipForNow() {
|
||||
await completeOnboarding()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
30
neode-ui/src/views/RootRedirect.vue
Normal file
30
neode-ui/src/views/RootRedirect.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center bg-black/40">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<svg class="animate-spin h-10 w-10 text-white/80" 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>
|
||||
<p class="text-white/60 text-sm">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { isOnboardingComplete } from '@/composables/useOnboarding'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(async () => {
|
||||
const devMode = import.meta.env.VITE_DEV_MODE
|
||||
if (devMode === 'setup' || devMode === 'existing') {
|
||||
router.replace('/login')
|
||||
return
|
||||
}
|
||||
|
||||
const seenOnboarding = await isOnboardingComplete()
|
||||
router.replace(seenOnboarding ? '/login' : '/onboarding/intro')
|
||||
})
|
||||
</script>
|
||||
@@ -8,22 +8,22 @@
|
||||
|
||||
<!-- Quick Actions Container -->
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<!-- Service Status -->
|
||||
<div class="flex items-center justify-between p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative">
|
||||
<div class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="relative shrink-0">
|
||||
<div class="w-3 h-3 rounded-full" :class="servicesRunning ? 'bg-green-400' : 'bg-red-400'"></div>
|
||||
<div v-if="servicesRunning" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-white">Services</p>
|
||||
<p class="text-xs text-white/60">{{ servicesRunning ? 'All Running' : 'Some Stopped' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="restartServices"
|
||||
class="px-3 py-1.5 glass-button rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
:disabled="restarting"
|
||||
>
|
||||
{{ restarting ? 'Restarting...' : 'Restart' }}
|
||||
@@ -31,20 +31,20 @@
|
||||
</div>
|
||||
|
||||
<!-- Connectivity Status -->
|
||||
<div class="flex items-center justify-between p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative">
|
||||
<div class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="relative shrink-0">
|
||||
<div class="w-3 h-3 rounded-full" :class="connectivityStatus === 'connected' ? 'bg-green-400' : connectivityStatus === 'checking' ? 'bg-yellow-400' : 'bg-red-400'"></div>
|
||||
<div v-if="connectivityStatus === 'connected'" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-white">Connectivity</p>
|
||||
<p class="text-xs text-white/60 capitalize">{{ connectivityStatus }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="checkConnectivity"
|
||||
class="px-3 py-1.5 glass-button rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
:disabled="checkingConnectivity"
|
||||
>
|
||||
{{ checkingConnectivity ? 'Checking...' : 'Check' }}
|
||||
@@ -52,19 +52,19 @@
|
||||
</div>
|
||||
|
||||
<!-- Auto-Sync Toggle -->
|
||||
<div class="flex items-center justify-between p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
|
||||
<div class="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="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 class="min-w-0">
|
||||
<p class="text-sm font-medium text-white">Auto-Sync</p>
|
||||
<p class="text-xs text-white/60">{{ autoSyncEnabled ? 'Enabled' : 'Disabled' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="toggleAutoSync"
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors self-start"
|
||||
:class="autoSyncEnabled ? 'bg-green-500' : 'bg-white/20'"
|
||||
>
|
||||
<span
|
||||
@@ -75,19 +75,19 @@
|
||||
</div>
|
||||
|
||||
<!-- Logs & Diagnostics -->
|
||||
<div class="flex items-center justify-between p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
|
||||
<div class="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="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" />
|
||||
</svg>
|
||||
<div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-white">Logs</p>
|
||||
<p class="text-xs text-white/60">{{ logCount > 0 ? `${logCount} new` : 'No new logs' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="viewLogs"
|
||||
class="px-3 py-1.5 glass-button rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
@@ -98,8 +98,8 @@
|
||||
<!-- Overview Cards -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<!-- Local Network Card -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="glass-card p-6 flex flex-col h-full min-h-0">
|
||||
<div class="flex items-start gap-4 mb-4 shrink-0">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
|
||||
@@ -111,7 +111,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-3 flex-1 min-h-0">
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -153,14 +153,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="mt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors">
|
||||
<button class="mt-auto pt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors shrink-0">
|
||||
Manage Local Network
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Web3 Card -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="glass-card p-6 flex flex-col h-full min-h-0">
|
||||
<div class="flex items-start gap-4 mb-4 shrink-0">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
@@ -172,7 +172,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-3 flex-1 min-h-0">
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -214,7 +214,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="mt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors">
|
||||
<button class="mt-auto pt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors shrink-0">
|
||||
Manage Web3 Services
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2 drop-shadow-[0_2px_8px_rgba(0,0,0,0.6)]">Settings</h1>
|
||||
<p class="text-white/80">Configure your Archipelago experience</p>
|
||||
<div class="mb-8 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2 drop-shadow-[0_2px_8px_rgba(0,0,0,0.6)]">Settings</h1>
|
||||
<p class="text-white/80">Configure your Archipelago experience</p>
|
||||
</div>
|
||||
<!-- Controller indicator - Mobile only (desktop shows in sidebar) -->
|
||||
<div class="md:hidden">
|
||||
<ControllerIndicator />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account Section -->
|
||||
@@ -43,8 +49,131 @@
|
||||
</div>
|
||||
<p class="text-base font-medium text-white/90">Currently logged in</p>
|
||||
</div>
|
||||
|
||||
<!-- Identity Card: DID + Tor Address (onion below DID, with copy) -->
|
||||
<div v-if="userDid || serverTorAddress" class="bg-black/20 rounded-xl px-5 py-4 border border-white/10 md:col-span-2 space-y-4">
|
||||
<!-- DID -->
|
||||
<div v-if="userDid">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<svg class="w-5 h-5 text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">Your DID</p>
|
||||
</div>
|
||||
<p class="text-sm font-mono text-white/90 break-all" :title="userDid">{{ userDid }}</p>
|
||||
<p class="text-xs text-white/50 mt-1">Decentralized identifier for passwordless auth</p>
|
||||
</div>
|
||||
|
||||
<!-- Tor / Onion Address (below DID, with copy button) -->
|
||||
<div v-if="serverTorAddress" :class="userDid ? 'pt-4 border-t border-white/10' : ''">
|
||||
<div class="flex items-center justify-between gap-2 mb-2">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<svg class="w-5 h-5 text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">Node .onion Address</p>
|
||||
</div>
|
||||
<button
|
||||
@click="copyOnionAddress"
|
||||
class="shrink-0 px-3 py-1.5 rounded-lg glass-button glass-button-sm text-xs font-medium text-white/90 hover:text-white transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<svg v-if="!copiedOnion" 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 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span v-else class="text-green-400 text-xs">Copied</span>
|
||||
<span v-if="!copiedOnion">Copy</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm font-mono text-amber-400/90 break-all" :title="serverTorAddress">{{ serverTorAddress }}</p>
|
||||
<p class="text-xs text-white/50 mt-1">Onion address for node interface and peer discovery over Tor</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change Password -->
|
||||
<div class="mb-6">
|
||||
<button
|
||||
@click="showChangePasswordModal = true"
|
||||
class="w-full flex items-center justify-center gap-2 mb-4 px-4 py-2 rounded-lg border border-orange-500/50 text-orange-400 font-medium hover:bg-orange-500/10 transition-colors"
|
||||
>
|
||||
<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="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
<span>Change Password</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Change Password Modal -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showChangePasswordModal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
||||
@click.self="showChangePasswordModal = false"
|
||||
>
|
||||
<div class="glass-card p-6 max-w-md w-full">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Change Password</h3>
|
||||
<p class="text-white/70 text-sm mb-4">Updates both web login and SSH access. Use a strong password (12+ chars, upper, lower, digit, special).</p>
|
||||
<form @submit.prevent="handleChangePassword" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">Current Password</label>
|
||||
<input
|
||||
v-model="changePasswordForm.currentPassword"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
|
||||
placeholder="Enter current password"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">New Password</label>
|
||||
<input
|
||||
v-model="changePasswordForm.newPassword"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
|
||||
placeholder="12+ chars, upper, lower, digit, special"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">Confirm New Password</label>
|
||||
<input
|
||||
v-model="changePasswordForm.confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
|
||||
placeholder="Re-enter new password"
|
||||
/>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-white/80">
|
||||
<input v-model="changePasswordForm.alsoChangeSsh" type="checkbox" class="rounded border-white/30" />
|
||||
Also update SSH password (recommended)
|
||||
</label>
|
||||
<p v-if="changePasswordError" class="text-sm text-red-400">{{ changePasswordError }}</p>
|
||||
<p v-if="changePasswordSuccess" class="text-sm text-green-400">{{ changePasswordSuccess }}</p>
|
||||
<div class="flex gap-3 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="changingPassword"
|
||||
class="flex-1 px-4 py-2 rounded-lg bg-orange-500 text-white font-medium hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{{ changingPassword ? 'Updating...' : 'Update Password' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="closeChangePasswordModal"
|
||||
class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Logout Button -->
|
||||
<button
|
||||
@click="handleLogout"
|
||||
@@ -72,15 +201,122 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import ControllerIndicator from '@/components/ControllerIndicator.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useAppStore()
|
||||
|
||||
const serverName = computed(() => store.serverName)
|
||||
const version = computed(() => store.serverInfo?.version || '0.0.0')
|
||||
const serverTorAddressFromStore = computed(() => store.serverInfo?.['tor-address'] || null)
|
||||
const torAddressFromRpc = ref<string | null>(null)
|
||||
const serverTorAddress = computed(() => serverTorAddressFromStore.value || torAddressFromRpc.value)
|
||||
const userDid = computed(() => {
|
||||
try {
|
||||
return localStorage.getItem('neode_did') || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const copiedOnion = ref(false)
|
||||
const showChangePasswordModal = ref(false)
|
||||
const changingPassword = ref(false)
|
||||
const changePasswordError = ref('')
|
||||
const changePasswordSuccess = ref('')
|
||||
const changePasswordForm = ref({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
alsoChangeSsh: true,
|
||||
})
|
||||
|
||||
function validatePasswordStrength(pw: string): string | null {
|
||||
if (pw.length < 12) return 'Password must be at least 12 characters'
|
||||
if (!/[A-Z]/.test(pw)) return 'Password must contain at least one uppercase letter'
|
||||
if (!/[a-z]/.test(pw)) return 'Password must contain at least one lowercase letter'
|
||||
if (!/\d/.test(pw)) return 'Password must contain at least one digit'
|
||||
if (!/[^A-Za-z0-9]/.test(pw)) return 'Password must contain at least one special character (!@#$%^&* etc.)'
|
||||
return null
|
||||
}
|
||||
|
||||
async function handleChangePassword() {
|
||||
changePasswordError.value = ''
|
||||
changePasswordSuccess.value = ''
|
||||
const { currentPassword, newPassword, confirmPassword, alsoChangeSsh } = changePasswordForm.value
|
||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||
changePasswordError.value = 'All fields are required'
|
||||
return
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
changePasswordError.value = 'New passwords do not match'
|
||||
return
|
||||
}
|
||||
const strengthError = validatePasswordStrength(newPassword)
|
||||
if (strengthError) {
|
||||
changePasswordError.value = strengthError
|
||||
return
|
||||
}
|
||||
changingPassword.value = true
|
||||
try {
|
||||
await rpcClient.changePassword({
|
||||
currentPassword,
|
||||
newPassword,
|
||||
alsoChangeSsh,
|
||||
})
|
||||
changePasswordSuccess.value = 'Password updated successfully. Use the new password for login and SSH.'
|
||||
changePasswordForm.value = { currentPassword: '', newPassword: '', confirmPassword: '', alsoChangeSsh: true }
|
||||
setTimeout(() => {
|
||||
closeChangePasswordModal()
|
||||
}, 2000)
|
||||
} catch (e) {
|
||||
changePasswordError.value = e instanceof Error ? e.message : 'Failed to change password'
|
||||
} finally {
|
||||
changingPassword.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyOnionAddress() {
|
||||
const addr = serverTorAddress.value
|
||||
if (!addr) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(addr)
|
||||
copiedOnion.value = true
|
||||
setTimeout(() => { copiedOnion.value = false }, 2000)
|
||||
} catch {
|
||||
// Fallback for older browsers
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = addr
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
copiedOnion.value = true
|
||||
setTimeout(() => { copiedOnion.value = false }, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
function closeChangePasswordModal() {
|
||||
showChangePasswordModal.value = false
|
||||
changePasswordError.value = ''
|
||||
changePasswordSuccess.value = ''
|
||||
changePasswordForm.value = { currentPassword: '', newPassword: '', confirmPassword: '', alsoChangeSsh: true }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!serverTorAddressFromStore.value) {
|
||||
try {
|
||||
const res = await rpcClient.getTorAddress()
|
||||
torAddressFromRpc.value = res.tor_address ?? null
|
||||
} catch {
|
||||
// Ignore - tor address may not be available yet
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function handleLogout() {
|
||||
await store.logout()
|
||||
|
||||
@@ -8,14 +8,14 @@
|
||||
|
||||
<!-- Quick Actions Container -->
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
|
||||
<!-- Networking Profits -->
|
||||
<div class="flex items-center justify-between p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative">
|
||||
<div class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="relative shrink-0">
|
||||
<span class="text-2xl text-orange-500 font-bold">₿</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-white">Networking Profits</p>
|
||||
<p class="text-xs text-orange-500 font-medium">₿0.024</p>
|
||||
</div>
|
||||
@@ -23,40 +23,41 @@
|
||||
</div>
|
||||
|
||||
<!-- DID Status -->
|
||||
<div class="flex items-center justify-between p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative">
|
||||
<div class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="relative shrink-0">
|
||||
<div class="w-3 h-3 rounded-full" :class="didStatus === 'active' ? 'bg-green-400' : 'bg-yellow-400'"></div>
|
||||
<div v-if="didStatus === 'active'" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-white">DID Status</p>
|
||||
<p class="text-xs text-white/60 capitalize">{{ didStatus }}</p>
|
||||
<p v-if="userDid" class="text-xs text-white/60 font-mono truncate" :title="userDid">{{ userDid }}</p>
|
||||
<p v-else class="text-xs text-white/60 capitalize">{{ didStatus }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="manageDIDs"
|
||||
class="px-3 py-1.5 glass-button rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
Manage
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Wallet Connection -->
|
||||
<div class="flex items-center justify-between p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative">
|
||||
<div class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="relative shrink-0">
|
||||
<div class="w-3 h-3 rounded-full" :class="walletConnected ? 'bg-green-400' : 'bg-red-400'"></div>
|
||||
<div v-if="walletConnected" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-white">Wallet</p>
|
||||
<p class="text-xs text-white/60">{{ walletConnected ? 'Connected' : 'Disconnected' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="connectWallet"
|
||||
class="px-3 py-1.5 glass-button rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
:disabled="connectingWallet"
|
||||
>
|
||||
{{ connectingWallet ? 'Connecting...' : walletConnected ? 'Disconnect' : 'Connect' }}
|
||||
@@ -64,32 +65,102 @@
|
||||
</div>
|
||||
|
||||
<!-- Nostr Relay Status -->
|
||||
<div class="flex items-center justify-between p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative">
|
||||
<div class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="relative shrink-0">
|
||||
<div class="w-3 h-3 rounded-full" :class="nostrRelaysConnected > 0 ? 'bg-green-400' : 'bg-red-400'"></div>
|
||||
<div v-if="nostrRelaysConnected > 0" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-white">Nostr Relays</p>
|
||||
<p class="text-xs text-white/60">{{ nostrRelaysConnected }} connected</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="manageRelays"
|
||||
class="px-3 py-1.5 glass-button rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
Manage
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Connected Nodes -->
|
||||
<div class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="relative shrink-0">
|
||||
<div class="w-3 h-3 rounded-full" :class="connectedNodesCount > 0 ? 'bg-green-400' : 'bg-amber-400'"></div>
|
||||
<div v-if="connectedNodesCount > 0" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-pulse opacity-75"></div>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-white">Connected Nodes</p>
|
||||
<p class="text-xs text-white/60">{{ connectedNodesCount }} peer{{ connectedNodesCount !== 1 ? 's' : '' }} known</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="showSendMessageModal = true"
|
||||
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
Send Message
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Send Message Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showSendMessageModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" @click.self="showSendMessageModal = false">
|
||||
<div class="glass-card p-6 max-w-md w-full max-h-[90vh] overflow-y-auto">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Send Message (over Tor)</h3>
|
||||
<p class="text-white/70 text-sm mb-4">Messages are sent over the Tor network to the selected peer.</p>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">To</label>
|
||||
<select
|
||||
v-model="sendMessageTo"
|
||||
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
|
||||
>
|
||||
<option value="">Select a peer...</option>
|
||||
<option v-for="p in peers" :key="p.pubkey" :value="p.onion">
|
||||
{{ p.name || p.onion || p.pubkey.slice(0, 12) + '...' }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">Message</label>
|
||||
<textarea
|
||||
v-model="sendMessageText"
|
||||
rows="3"
|
||||
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
|
||||
placeholder="Type your message..."
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button
|
||||
@click="sendMessage"
|
||||
:disabled="!sendMessageTo || !sendMessageText.trim() || sendingMessage"
|
||||
class="flex-1 px-4 py-2 rounded-lg bg-orange-500 text-white font-medium hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{{ sendingMessage ? 'Sending...' : 'Send' }}
|
||||
</button>
|
||||
<button
|
||||
@click="showSendMessageModal = false"
|
||||
class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="sendMessageError" class="mt-3 text-sm text-red-400">{{ sendMessageError }}</p>
|
||||
<p v-if="sendMessageSuccess" class="mt-3 text-sm text-green-400">{{ sendMessageSuccess }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Core Services Overview Cards -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
|
||||
<!-- Bitcoin Domain Name Portfolio -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="glass-card p-6 flex flex-col h-full min-h-0">
|
||||
<div class="flex items-start gap-4 mb-4 shrink-0">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
||||
@@ -101,7 +172,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-3 flex-1 min-h-0">
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -133,14 +204,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="mt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors">
|
||||
<button class="mt-auto pt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors shrink-0">
|
||||
Manage Domains
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Web5 Wallet -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="glass-card p-6 flex flex-col h-full min-h-0">
|
||||
<div class="flex items-start gap-4 mb-4 shrink-0">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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" />
|
||||
@@ -152,7 +223,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-3 flex-1 min-h-0">
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-lg text-orange-500 font-bold">₿</span>
|
||||
@@ -182,14 +253,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="mt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors">
|
||||
<button class="mt-auto pt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors shrink-0">
|
||||
Open Wallet
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Nostr Relays -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="glass-card p-6 flex flex-col h-full min-h-0">
|
||||
<div class="flex items-start gap-4 mb-4 shrink-0">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
|
||||
@@ -201,7 +272,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-3 flex-1 min-h-0">
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -233,10 +304,116 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="mt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors">
|
||||
<button class="mt-auto pt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors shrink-0">
|
||||
Manage Relays
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Connected Nodes (P2P over Tor) -->
|
||||
<div ref="nodesContainerRef" class="glass-card p-6 lg:col-span-3 scroll-mt-24">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h2 class="text-xl font-semibold text-white mb-2">Connected Nodes</h2>
|
||||
<p class="text-white/70 text-sm mb-4">Peer nodes discovered via Nostr. Messages sent over Tor.</p>
|
||||
</div>
|
||||
<button
|
||||
@click="loadPeers"
|
||||
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors shrink-0"
|
||||
>
|
||||
{{ loadingPeers ? '...' : 'Refresh' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabs: Peers | Messages -->
|
||||
<div class="flex gap-1 mb-4 border-b border-white/10">
|
||||
<button
|
||||
@click="nodesContainerTab = 'peers'"
|
||||
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
|
||||
:class="nodesContainerTab === 'peers' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
|
||||
>
|
||||
Peers
|
||||
<span v-if="peers.length > 0" class="ml-1.5 text-xs text-white/50">({{ peers.length }})</span>
|
||||
</button>
|
||||
<button
|
||||
@click="switchToMessagesTab"
|
||||
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors flex items-center gap-1.5"
|
||||
:class="nodesContainerTab === 'messages' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
|
||||
>
|
||||
Messages
|
||||
<span v-if="receivedMessages.length > 0" class="ml-1.5 text-xs" :class="unreadCount > 0 ? 'text-orange-400' : 'text-white/50'">({{ receivedMessages.length }})</span>
|
||||
<span v-if="unreadCount > 0" class="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Peers tab -->
|
||||
<div v-show="nodesContainerTab === 'peers'" class="space-y-2 max-h-48 overflow-y-auto">
|
||||
<div v-if="peers.length === 0" class="p-4 text-center text-white/60 text-sm">
|
||||
No peers yet. Add a peer manually or use Discover to find nodes on Nostr.
|
||||
</div>
|
||||
<div
|
||||
v-for="p in peers"
|
||||
:key="p.pubkey"
|
||||
class="flex items-center justify-between p-3 bg-white/5 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="w-2 h-2 rounded-full shrink-0" :class="peerReachable[p.onion] ? 'bg-green-400' : 'bg-amber-400'"></div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-mono text-white/90 truncate">{{ p.name || p.onion || p.pubkey.slice(0, 16) + '...' }}</p>
|
||||
<p class="text-xs text-white/50 truncate">{{ p.onion }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="showSendMessageModal = true; sendMessageTo = p.onion"
|
||||
class="px-2 py-1 text-xs rounded bg-orange-500/20 text-orange-400 hover:bg-orange-500/30 transition-colors shrink-0"
|
||||
>
|
||||
Message
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages tab -->
|
||||
<div v-show="nodesContainerTab === 'messages'" class="space-y-2 max-h-64 overflow-y-auto">
|
||||
<div v-if="loadingMessages" class="p-4 text-center text-white/60 text-sm">
|
||||
Loading messages...
|
||||
</div>
|
||||
<div v-else-if="receivedMessages.length === 0" class="p-4 text-center text-white/60 text-sm">
|
||||
No messages yet. Messages from peers will appear here.
|
||||
</div>
|
||||
<div
|
||||
v-for="(m, idx) in receivedMessages"
|
||||
:key="idx"
|
||||
class="p-3 bg-white/5 rounded-lg border-l-2 border-orange-500/50"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2 mb-1">
|
||||
<p class="text-xs font-mono text-white/60 truncate" :title="m.from_pubkey">{{ m.from_pubkey.slice(0, 16) }}...</p>
|
||||
<span class="text-xs text-white/40 shrink-0">{{ formatMessageTime(m.timestamp) }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-white/90 break-words">{{ m.message }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="nodesContainerTab === 'peers'"
|
||||
@click="discoverAndAddPeers"
|
||||
:disabled="discovering"
|
||||
class="mt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
{{ discovering ? 'Discovering...' : 'Discover Nodes on Nostr' }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@click="loadReceivedMessages"
|
||||
:disabled="loadingMessages"
|
||||
class="mt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
{{ loadingMessages ? 'Loading...' : 'Refresh Messages' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Protocol Overview Cards -->
|
||||
@@ -449,10 +626,26 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed, onMounted, nextTick, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useMessageToast } from '@/composables/useMessageToast'
|
||||
|
||||
// DID Status: 'active' | 'inactive' | 'pending'
|
||||
const didStatus = ref<'active' | 'inactive' | 'pending'>('active')
|
||||
const route = useRoute()
|
||||
const messageToast = useMessageToast()
|
||||
|
||||
const userDid = computed(() => {
|
||||
try {
|
||||
return localStorage.getItem('neode_did') || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
// DID Status: 'active' when user has DID, else 'inactive'
|
||||
const didStatus = computed<'active' | 'inactive' | 'pending'>(() =>
|
||||
userDid.value ? 'active' : 'inactive'
|
||||
)
|
||||
|
||||
// DWN Sync Status: 'synced' | 'syncing' | 'error'
|
||||
const dwnSyncStatus = ref<'synced' | 'syncing' | 'error'>('synced')
|
||||
@@ -465,6 +658,130 @@ const connectingWallet = ref(false)
|
||||
// Nostr Relays
|
||||
const nostrRelaysConnected = ref(8)
|
||||
|
||||
// Connected Nodes (peers)
|
||||
const peers = ref<Array<{ onion: string; pubkey: string; name?: string }>>([])
|
||||
const loadingPeers = ref(false)
|
||||
const peerReachable = ref<Record<string, boolean>>({})
|
||||
const connectedNodesCount = computed(() => peers.value.length)
|
||||
|
||||
// Send Message modal
|
||||
const showSendMessageModal = ref(false)
|
||||
const sendMessageTo = ref('')
|
||||
const sendMessageText = ref('')
|
||||
const sendingMessage = ref(false)
|
||||
const sendMessageError = ref('')
|
||||
const sendMessageSuccess = ref('')
|
||||
const discovering = ref(false)
|
||||
|
||||
// Connected Nodes container: tabs + messages (uses shared composable for polling from Dashboard)
|
||||
const nodesContainerRef = ref<HTMLElement | null>(null)
|
||||
const nodesContainerTab = ref<'peers' | 'messages'>('peers')
|
||||
const { receivedMessages, loadingMessages, unreadCount, loadReceivedMessages, markAsRead } = messageToast
|
||||
|
||||
function formatMessageTime(ts: string): string {
|
||||
try {
|
||||
const d = new Date(ts)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - d.getTime()
|
||||
if (diff < 60000) return 'Just now'
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`
|
||||
return d.toLocaleDateString()
|
||||
} catch {
|
||||
return ts
|
||||
}
|
||||
}
|
||||
|
||||
function switchToMessagesTab() {
|
||||
nodesContainerTab.value = 'messages'
|
||||
markAsRead()
|
||||
}
|
||||
|
||||
async function loadPeers() {
|
||||
loadingPeers.value = true
|
||||
try {
|
||||
const res = await rpcClient.listPeers()
|
||||
peers.value = res.peers || []
|
||||
for (const p of peers.value) {
|
||||
try {
|
||||
const check = await rpcClient.checkPeerReachable(p.onion)
|
||||
peerReachable.value[p.onion] = check.reachable
|
||||
} catch {
|
||||
peerReachable.value[p.onion] = false
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load peers:', e)
|
||||
} finally {
|
||||
loadingPeers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
if (!sendMessageTo.value || !sendMessageText.value.trim()) return
|
||||
sendingMessage.value = true
|
||||
sendMessageError.value = ''
|
||||
sendMessageSuccess.value = ''
|
||||
try {
|
||||
await rpcClient.sendMessageToPeer(sendMessageTo.value, sendMessageText.value.trim())
|
||||
sendMessageSuccess.value = 'Message sent over Tor!'
|
||||
sendMessageText.value = ''
|
||||
setTimeout(() => {
|
||||
showSendMessageModal.value = false
|
||||
sendMessageSuccess.value = ''
|
||||
}, 1500)
|
||||
} catch (e) {
|
||||
sendMessageError.value = e instanceof Error ? e.message : 'Failed to send'
|
||||
} finally {
|
||||
sendingMessage.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverAndAddPeers() {
|
||||
discovering.value = true
|
||||
try {
|
||||
const res = await rpcClient.discoverNodes()
|
||||
const nodes = res.nodes || []
|
||||
for (const n of nodes) {
|
||||
if (n.onion && n.pubkey) {
|
||||
try {
|
||||
await rpcClient.addPeer({ onion: n.onion, pubkey: n.pubkey })
|
||||
} catch {
|
||||
// may already exist
|
||||
}
|
||||
}
|
||||
}
|
||||
await loadPeers()
|
||||
} catch (e) {
|
||||
console.error('Discover failed:', e)
|
||||
} finally {
|
||||
discovering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadPeers()
|
||||
loadReceivedMessages()
|
||||
// Open Messages tab when navigated via toast (e.g. ?tab=messages)
|
||||
if (route.query.tab === 'messages') {
|
||||
nodesContainerTab.value = 'messages'
|
||||
markAsRead()
|
||||
nextTick(() => {
|
||||
nodesContainerRef.value?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => route.query.tab, (tab) => {
|
||||
if (tab === 'messages') {
|
||||
nodesContainerTab.value = 'messages'
|
||||
markAsRead()
|
||||
nextTick(() => {
|
||||
nodesContainerRef.value?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function manageDIDs() {
|
||||
// TODO: Navigate to DID management or open modal
|
||||
console.log('Managing DIDs...')
|
||||
@@ -506,3 +823,4 @@ function manageRelays() {
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user