feat: implement three-mode UI system (Easy / Pro / Chat)
Add switchable UI modes with conditional rendering: - Easy mode: goal-based interface with 7 guided workflows - Pro mode: current technical interface preserved with Quick Start Goals - Chat mode: AIUI placeholder for future integration New components: ModeSwitcher, EasyHome, GoalDetail wizard, Chat placeholder New stores: uiMode (mode persistence), goals (progress tracking) New data: goal definitions catalog, helpTree goals section Modified: Dashboard (reactive nav), Home (mode dispatcher), Settings (mode picker), Router (goal/chat routes), Spotlight (goal search integration) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
486fc39249
commit
7b044d22ef
78
neode-ui/src/components/EasyHome.vue
Normal file
78
neode-ui/src/components/EasyHome.vue
Normal file
@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 mb-8 transition-opacity duration-300"
|
||||
:class="{ 'opacity-0 pointer-events-none': !show }"
|
||||
>
|
||||
<RouterLink
|
||||
v-for="(goal, idx) in goals"
|
||||
:key="goal.id"
|
||||
:to="`/dashboard/goals/${goal.id}`"
|
||||
class="goal-card glass-card p-6 block"
|
||||
:class="{ 'home-card-animate': animate }"
|
||||
:style="{ '--card-stagger': idx }"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="w-10 h-10 rounded-xl bg-white/10 flex items-center justify-center shrink-0">
|
||||
<span class="text-xl">{{ goalIcon(goal.icon) }}</span>
|
||||
</div>
|
||||
<span class="goal-status-badge" :class="statusBadgeClass(goal.id)">
|
||||
<span v-if="goalStatuses[goal.id] === 'completed'" class="w-1.5 h-1.5 rounded-full bg-green-400"></span>
|
||||
<span v-else-if="goalStatuses[goal.id] === 'in-progress'" class="w-1.5 h-1.5 rounded-full bg-orange-400"></span>
|
||||
{{ statusLabel(goal.id) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-semibold text-white mb-1">{{ goal.title }}</h3>
|
||||
<p class="text-sm text-white/55 mb-4 leading-relaxed">{{ goal.subtitle }}</p>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-white/40">{{ goal.estimatedTime }}</span>
|
||||
<span class="text-xs text-white/50 flex items-center gap-1">
|
||||
{{ goal.difficulty === 'beginner' ? 'Beginner' : 'Intermediate' }}
|
||||
</span>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { GOALS } from '@/data/goals'
|
||||
import { useGoalStore } from '@/stores/goals'
|
||||
|
||||
defineProps<{
|
||||
show: boolean
|
||||
animate: boolean
|
||||
}>()
|
||||
|
||||
const goalStore = useGoalStore()
|
||||
const goals = GOALS
|
||||
const goalStatuses = goalStore.goalStatuses
|
||||
|
||||
function goalIcon(icon: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
shop: '🏪',
|
||||
payments: '⚡',
|
||||
photos: '📸',
|
||||
files: '📁',
|
||||
lightning: '⚡',
|
||||
identity: '🔑',
|
||||
backup: '💾',
|
||||
}
|
||||
return icons[icon] || '📦'
|
||||
}
|
||||
|
||||
function statusLabel(goalId: string): string {
|
||||
const status = goalStatuses[goalId]
|
||||
if (status === 'completed') return 'Done'
|
||||
if (status === 'in-progress') return 'In Progress'
|
||||
return 'Start'
|
||||
}
|
||||
|
||||
function statusBadgeClass(goalId: string): string {
|
||||
const status = goalStatuses[goalId]
|
||||
if (status === 'completed') return 'goal-status-badge-completed'
|
||||
if (status === 'in-progress') return 'goal-status-badge-in-progress'
|
||||
return 'goal-status-badge-not-started'
|
||||
}
|
||||
</script>
|
||||
26
neode-ui/src/components/ModeSwitcher.vue
Normal file
26
neode-ui/src/components/ModeSwitcher.vue
Normal file
@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<div class="mode-switcher">
|
||||
<button
|
||||
v-for="m in modes"
|
||||
:key="m.id"
|
||||
@click="uiMode.setMode(m.id)"
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': uiMode.mode === m.id }"
|
||||
>
|
||||
{{ m.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useUIModeStore } from '@/stores/uiMode'
|
||||
import type { UIMode } from '@/types/api'
|
||||
|
||||
const uiMode = useUIModeStore()
|
||||
|
||||
const modes: { id: UIMode; label: string }[] = [
|
||||
{ id: 'easy', label: 'Easy' },
|
||||
{ id: 'gamer', label: 'Pro' },
|
||||
{ id: 'chat', label: 'Chat' },
|
||||
]
|
||||
</script>
|
||||
@ -237,7 +237,7 @@ function selectHelpItem(section: { id: string }, item: { id: string; label: stri
|
||||
}
|
||||
}
|
||||
|
||||
function selectRecent(item: { id: string; label: string; path?: string; type: 'navigate' | 'learn' | 'action' }) {
|
||||
function selectRecent(item: { id: string; label: string; path?: string; type: 'navigate' | 'learn' | 'action' | 'goal' }) {
|
||||
spotlightStore.close()
|
||||
if (item.path === '__cli__') {
|
||||
cliStore.open()
|
||||
|
||||
262
neode-ui/src/data/goals.ts
Normal file
262
neode-ui/src/data/goals.ts
Normal file
@ -0,0 +1,262 @@
|
||||
import type { GoalDefinition } from '@/types/goals'
|
||||
|
||||
export const GOALS: GoalDefinition[] = [
|
||||
{
|
||||
id: 'open-a-shop',
|
||||
title: 'Open a Shop',
|
||||
subtitle: 'Accept Bitcoin payments with your own online store',
|
||||
icon: 'shop',
|
||||
category: 'commerce',
|
||||
requiredApps: ['bitcoin-knots', 'lnd', 'btcpay-server'],
|
||||
steps: [
|
||||
{
|
||||
id: 'install-bitcoin',
|
||||
title: 'Install Bitcoin Node',
|
||||
description: 'Bitcoin Knots validates transactions and maintains the blockchain on your hardware. This is the foundation of your sovereign payment stack.',
|
||||
appId: 'bitcoin-knots',
|
||||
action: 'install',
|
||||
isAutomatic: true,
|
||||
},
|
||||
{
|
||||
id: 'install-lnd',
|
||||
title: 'Install Lightning Network',
|
||||
description: 'LND enables instant, low-fee Bitcoin payments through payment channels. Your customers can pay in seconds.',
|
||||
appId: 'lnd',
|
||||
action: 'install',
|
||||
isAutomatic: true,
|
||||
},
|
||||
{
|
||||
id: 'install-btcpay',
|
||||
title: 'Install BTCPay Server',
|
||||
description: 'BTCPay Server is your self-hosted payment processor. Create invoices, manage your store, and accept payments — all without middlemen.',
|
||||
appId: 'btcpay-server',
|
||||
action: 'install',
|
||||
isAutomatic: true,
|
||||
},
|
||||
{
|
||||
id: 'configure-store',
|
||||
title: 'Set Up Your Store',
|
||||
description: 'Create your store, set your currency, and customize your payment page. BTCPay will open so you can configure everything.',
|
||||
action: 'configure',
|
||||
isAutomatic: false,
|
||||
},
|
||||
],
|
||||
estimatedTime: '~45 min + sync time',
|
||||
difficulty: 'beginner',
|
||||
},
|
||||
{
|
||||
id: 'accept-payments',
|
||||
title: 'Accept Payments',
|
||||
subtitle: 'Receive Bitcoin and Lightning payments directly',
|
||||
icon: 'payments',
|
||||
category: 'payments',
|
||||
requiredApps: ['bitcoin-knots', 'lnd'],
|
||||
steps: [
|
||||
{
|
||||
id: 'install-bitcoin',
|
||||
title: 'Install Bitcoin Node',
|
||||
description: 'Your own Bitcoin node verifies every transaction independently. No trust required.',
|
||||
appId: 'bitcoin-knots',
|
||||
action: 'install',
|
||||
isAutomatic: true,
|
||||
},
|
||||
{
|
||||
id: 'install-lnd',
|
||||
title: 'Install Lightning Network',
|
||||
description: 'Lightning enables instant payments with tiny fees. Perfect for everyday transactions.',
|
||||
appId: 'lnd',
|
||||
action: 'install',
|
||||
isAutomatic: true,
|
||||
},
|
||||
{
|
||||
id: 'open-channel',
|
||||
title: 'Open a Lightning Channel',
|
||||
description: 'Open your first payment channel to start sending and receiving Lightning payments. LND will guide you through it.',
|
||||
action: 'configure',
|
||||
isAutomatic: false,
|
||||
},
|
||||
],
|
||||
estimatedTime: '~30 min + sync time',
|
||||
difficulty: 'beginner',
|
||||
},
|
||||
{
|
||||
id: 'store-photos',
|
||||
title: 'Store My Photos',
|
||||
subtitle: 'Private photo backup and gallery on your own hardware',
|
||||
icon: 'photos',
|
||||
category: 'storage',
|
||||
requiredApps: ['immich'],
|
||||
steps: [
|
||||
{
|
||||
id: 'install-immich',
|
||||
title: 'Install Immich',
|
||||
description: 'Immich is a self-hosted photo and video management solution. It looks and feels like Google Photos, but your data stays on your server.',
|
||||
appId: 'immich',
|
||||
action: 'install',
|
||||
isAutomatic: true,
|
||||
},
|
||||
{
|
||||
id: 'configure-immich',
|
||||
title: 'Create Your Account',
|
||||
description: 'Set up your Immich account and configure your photo library. Quick and simple.',
|
||||
action: 'configure',
|
||||
isAutomatic: false,
|
||||
},
|
||||
{
|
||||
id: 'mobile-sync',
|
||||
title: 'Connect Your Phone',
|
||||
description: 'Download the Immich app on your phone and scan the QR code to start automatic photo backup.',
|
||||
action: 'info',
|
||||
isAutomatic: false,
|
||||
},
|
||||
],
|
||||
estimatedTime: '~15 min',
|
||||
difficulty: 'beginner',
|
||||
},
|
||||
{
|
||||
id: 'store-files',
|
||||
title: 'Store My Files',
|
||||
subtitle: 'Personal cloud storage and file sync',
|
||||
icon: 'files',
|
||||
category: 'storage',
|
||||
requiredApps: ['nextcloud'],
|
||||
steps: [
|
||||
{
|
||||
id: 'install-nextcloud',
|
||||
title: 'Install Cloud Storage',
|
||||
description: 'Nextcloud gives you a full cloud storage platform — files, calendars, contacts, and more. Like Dropbox, but sovereign.',
|
||||
appId: 'nextcloud',
|
||||
action: 'install',
|
||||
isAutomatic: true,
|
||||
},
|
||||
{
|
||||
id: 'configure-nextcloud',
|
||||
title: 'Set Up Your Cloud',
|
||||
description: 'Create your admin account and configure storage. Nextcloud will open for you to complete setup.',
|
||||
action: 'configure',
|
||||
isAutomatic: false,
|
||||
},
|
||||
{
|
||||
id: 'sync-setup',
|
||||
title: 'Sync Your Devices',
|
||||
description: 'Install the Nextcloud app on your phone and computer to keep your files in sync across all devices.',
|
||||
action: 'info',
|
||||
isAutomatic: false,
|
||||
},
|
||||
],
|
||||
estimatedTime: '~20 min',
|
||||
difficulty: 'beginner',
|
||||
},
|
||||
{
|
||||
id: 'run-lightning-node',
|
||||
title: 'Run a Lightning Node',
|
||||
subtitle: 'Route payments and earn sats on the Lightning Network',
|
||||
icon: 'lightning',
|
||||
category: 'network',
|
||||
requiredApps: ['bitcoin-knots', 'lnd'],
|
||||
steps: [
|
||||
{
|
||||
id: 'install-bitcoin',
|
||||
title: 'Install Bitcoin Node',
|
||||
description: 'The Bitcoin blockchain is the settlement layer. Your node needs to sync the full chain before Lightning can start.',
|
||||
appId: 'bitcoin-knots',
|
||||
action: 'install',
|
||||
isAutomatic: true,
|
||||
},
|
||||
{
|
||||
id: 'install-lnd',
|
||||
title: 'Install LND',
|
||||
description: 'LND is a full Lightning Network node. You can route payments for others and earn routing fees.',
|
||||
appId: 'lnd',
|
||||
action: 'install',
|
||||
isAutomatic: true,
|
||||
},
|
||||
{
|
||||
id: 'open-channels',
|
||||
title: 'Open Payment Channels',
|
||||
description: 'Open channels with well-connected nodes to start routing payments. More channels means more routing opportunities.',
|
||||
action: 'configure',
|
||||
isAutomatic: false,
|
||||
},
|
||||
{
|
||||
id: 'verify-routing',
|
||||
title: 'Verify Node is Routing',
|
||||
description: 'Check that your node is visible on the network and ready to route payments.',
|
||||
action: 'verify',
|
||||
isAutomatic: true,
|
||||
},
|
||||
],
|
||||
estimatedTime: '~40 min + sync time',
|
||||
difficulty: 'intermediate',
|
||||
},
|
||||
{
|
||||
id: 'create-identity',
|
||||
title: 'Create My Identity',
|
||||
subtitle: 'Sovereign digital identity with DID and Nostr',
|
||||
icon: 'identity',
|
||||
category: 'identity',
|
||||
requiredApps: [],
|
||||
steps: [
|
||||
{
|
||||
id: 'generate-did',
|
||||
title: 'Generate Your Identity',
|
||||
description: 'Your server creates a cryptographic identity (DID) that you own and control. No company can revoke it.',
|
||||
action: 'verify',
|
||||
isAutomatic: true,
|
||||
},
|
||||
{
|
||||
id: 'setup-nostr',
|
||||
title: 'Set Up Nostr Profile',
|
||||
description: 'Publish your identity to the Nostr network. This lets you sign into Nostr-compatible apps directly from your server.',
|
||||
action: 'configure',
|
||||
isAutomatic: false,
|
||||
},
|
||||
{
|
||||
id: 'export-identity',
|
||||
title: 'Export Your Identity',
|
||||
description: 'Save your identity credentials for backup. This is your portable sovereign identity — take it anywhere.',
|
||||
action: 'info',
|
||||
isAutomatic: false,
|
||||
},
|
||||
],
|
||||
estimatedTime: '~5 min',
|
||||
difficulty: 'beginner',
|
||||
},
|
||||
{
|
||||
id: 'back-up-everything',
|
||||
title: 'Back Up Everything',
|
||||
subtitle: 'Encrypted backup of your entire node',
|
||||
icon: 'backup',
|
||||
category: 'backup',
|
||||
requiredApps: [],
|
||||
steps: [
|
||||
{
|
||||
id: 'create-passphrase',
|
||||
title: 'Create a Passphrase',
|
||||
description: 'Choose a strong passphrase to encrypt your backup. Without this passphrase, nobody can access your data — not even us.',
|
||||
action: 'configure',
|
||||
isAutomatic: false,
|
||||
},
|
||||
{
|
||||
id: 'create-backup',
|
||||
title: 'Create Encrypted Backup',
|
||||
description: 'Your server will create a complete encrypted backup of all your data, keys, and configuration.',
|
||||
action: 'verify',
|
||||
isAutomatic: true,
|
||||
},
|
||||
{
|
||||
id: 'save-backup',
|
||||
title: 'Save Your Backup',
|
||||
description: 'Download your encrypted backup file and store it somewhere safe. Consider keeping a copy on a USB drive and in the cloud.',
|
||||
action: 'info',
|
||||
isAutomatic: false,
|
||||
},
|
||||
],
|
||||
estimatedTime: '~10 min',
|
||||
difficulty: 'beginner',
|
||||
},
|
||||
]
|
||||
|
||||
export function getGoalById(id: string): GoalDefinition | undefined {
|
||||
return GOALS.find((g) => g.id === id)
|
||||
}
|
||||
@ -16,7 +16,7 @@ export interface SearchableItem {
|
||||
id: string
|
||||
label: string
|
||||
path?: string
|
||||
type: 'navigate' | 'learn' | 'action'
|
||||
type: 'navigate' | 'learn' | 'action' | 'goal'
|
||||
section: string
|
||||
content?: string
|
||||
relatedPath?: string
|
||||
@ -71,6 +71,19 @@ export const helpTree: HelpSection[] = [
|
||||
{ id: 'backup', label: 'Backup & Recovery', path: '/dashboard/settings' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'goals',
|
||||
label: 'Quick Start Goals',
|
||||
items: [
|
||||
{ id: 'goal-shop', label: 'Open a Shop', path: '/dashboard/goals/open-a-shop' },
|
||||
{ id: 'goal-payments', label: 'Accept Payments', path: '/dashboard/goals/accept-payments' },
|
||||
{ id: 'goal-photos', label: 'Store My Photos', path: '/dashboard/goals/store-photos' },
|
||||
{ id: 'goal-files', label: 'Store My Files', path: '/dashboard/goals/store-files' },
|
||||
{ id: 'goal-lightning', label: 'Run a Lightning Node', path: '/dashboard/goals/run-lightning-node' },
|
||||
{ id: 'goal-identity', label: 'Create My Identity', path: '/dashboard/goals/create-identity' },
|
||||
{ id: 'goal-backup', label: 'Back Up Everything', path: '/dashboard/goals/back-up-everything' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function flattenForSearch(): SearchableItem[] {
|
||||
@ -81,7 +94,9 @@ export function flattenForSearch(): SearchableItem[] {
|
||||
? 'navigate'
|
||||
: section.id === 'learn'
|
||||
? 'learn'
|
||||
: 'action'
|
||||
: section.id === 'goals'
|
||||
? 'goal'
|
||||
: 'action'
|
||||
for (const item of section.items) {
|
||||
result.push({
|
||||
id: item.id,
|
||||
|
||||
@ -110,6 +110,16 @@ const router = createRouter({
|
||||
name: 'settings',
|
||||
component: () => import('../views/Settings.vue'),
|
||||
},
|
||||
{
|
||||
path: 'goals/:goalId',
|
||||
name: 'goal-detail',
|
||||
component: () => import('../views/GoalDetail.vue'),
|
||||
},
|
||||
{
|
||||
path: 'chat',
|
||||
name: 'chat',
|
||||
component: () => import('../views/Chat.vue'),
|
||||
},
|
||||
// Containers removed: My Apps serves the same purpose. Redirect old links.
|
||||
{
|
||||
path: 'containers',
|
||||
|
||||
105
neode-ui/src/stores/goals.ts
Normal file
105
neode-ui/src/stores/goals.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { GoalProgress, GoalStatus } from '@/types/goals'
|
||||
import { GOALS } from '@/data/goals'
|
||||
import { useAppStore } from './app'
|
||||
|
||||
const STORAGE_KEY = 'archipelago-goal-progress'
|
||||
|
||||
export const useGoalStore = defineStore('goals', () => {
|
||||
const progress = ref<Record<string, GoalProgress>>({})
|
||||
|
||||
function load() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw) progress.value = JSON.parse(raw)
|
||||
} catch {
|
||||
/* ignore corrupt data */
|
||||
}
|
||||
}
|
||||
|
||||
function save() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(progress.value))
|
||||
}
|
||||
|
||||
function getGoalStatus(goalId: string): GoalStatus {
|
||||
const goal = GOALS.find((g) => g.id === goalId)
|
||||
if (!goal) return 'not-started'
|
||||
|
||||
// Goals with no required apps use manual progress tracking
|
||||
if (goal.requiredApps.length === 0) {
|
||||
return progress.value[goalId]?.status || 'not-started'
|
||||
}
|
||||
|
||||
const appStore = useAppStore()
|
||||
const packages = appStore.packages
|
||||
|
||||
const allRunning = goal.requiredApps.every((appId) =>
|
||||
Object.entries(packages).some(
|
||||
([pkgId, pkg]) => pkgId === appId && pkg.state === 'running',
|
||||
),
|
||||
)
|
||||
if (allRunning) return 'completed'
|
||||
|
||||
const anyInstalled = goal.requiredApps.some((appId) =>
|
||||
Object.keys(packages).some((pkgId) => pkgId === appId),
|
||||
)
|
||||
if (anyInstalled || progress.value[goalId]) return 'in-progress'
|
||||
|
||||
return 'not-started'
|
||||
}
|
||||
|
||||
const goalStatuses = computed(() => {
|
||||
const statuses: Record<string, GoalStatus> = {}
|
||||
for (const goal of GOALS) {
|
||||
statuses[goal.id] = getGoalStatus(goal.id)
|
||||
}
|
||||
return statuses
|
||||
})
|
||||
|
||||
function startGoal(goalId: string) {
|
||||
progress.value[goalId] = {
|
||||
goalId,
|
||||
status: 'in-progress',
|
||||
currentStepIndex: 0,
|
||||
completedSteps: [],
|
||||
startedAt: Date.now(),
|
||||
}
|
||||
save()
|
||||
}
|
||||
|
||||
function completeStep(goalId: string, stepId: string) {
|
||||
const p = progress.value[goalId]
|
||||
if (!p) return
|
||||
|
||||
if (!p.completedSteps.includes(stepId)) {
|
||||
p.completedSteps.push(stepId)
|
||||
}
|
||||
|
||||
const goal = GOALS.find((g) => g.id === goalId)
|
||||
if (goal && p.completedSteps.length >= goal.steps.length) {
|
||||
p.status = 'completed'
|
||||
} else {
|
||||
p.currentStepIndex = Math.min(p.currentStepIndex + 1, (goal?.steps.length ?? 1) - 1)
|
||||
}
|
||||
|
||||
save()
|
||||
}
|
||||
|
||||
function resetGoal(goalId: string) {
|
||||
delete progress.value[goalId]
|
||||
save()
|
||||
}
|
||||
|
||||
// Load on store creation
|
||||
load()
|
||||
|
||||
return {
|
||||
progress,
|
||||
goalStatuses,
|
||||
getGoalStatus,
|
||||
startGoal,
|
||||
completeStep,
|
||||
resetGoal,
|
||||
}
|
||||
})
|
||||
@ -9,7 +9,7 @@ export interface RecentItem {
|
||||
id: string
|
||||
label: string
|
||||
path?: string
|
||||
type: 'navigate' | 'learn' | 'action'
|
||||
type: 'navigate' | 'learn' | 'action' | 'goal'
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
|
||||
33
neode-ui/src/stores/uiMode.ts
Normal file
33
neode-ui/src/stores/uiMode.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { UIMode } from '@/types/api'
|
||||
|
||||
const STORAGE_KEY = 'archipelago-ui-mode'
|
||||
|
||||
export const useUIModeStore = defineStore('uiMode', () => {
|
||||
const mode = ref<UIMode>(loadFromStorage())
|
||||
|
||||
function loadFromStorage(): UIMode {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored === 'gamer' || stored === 'easy' || stored === 'chat') return stored
|
||||
return 'gamer'
|
||||
}
|
||||
|
||||
function syncFromBackend(backendMode: UIMode | undefined) {
|
||||
if (backendMode && ['gamer', 'easy', 'chat'].includes(backendMode)) {
|
||||
mode.value = backendMode
|
||||
localStorage.setItem(STORAGE_KEY, backendMode)
|
||||
}
|
||||
}
|
||||
|
||||
function setMode(newMode: UIMode) {
|
||||
mode.value = newMode
|
||||
localStorage.setItem(STORAGE_KEY, newMode)
|
||||
}
|
||||
|
||||
const isGamer = computed(() => mode.value === 'gamer')
|
||||
const isEasy = computed(() => mode.value === 'easy')
|
||||
const isChat = computed(() => mode.value === 'chat')
|
||||
|
||||
return { mode, setMode, syncFromBackend, isGamer, isEasy, isChat }
|
||||
})
|
||||
@ -67,6 +67,95 @@
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
/* Mode switcher - sidebar toggle */
|
||||
.mode-switcher {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 3px;
|
||||
border-radius: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.mode-switcher-btn {
|
||||
flex: 1;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
transition: all 0.25s ease;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.mode-switcher-btn:hover {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.mode-switcher-btn-active {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Goal cards */
|
||||
.goal-card {
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.goal-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.goal-status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.goal-status-badge-not-started {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.goal-status-badge-in-progress {
|
||||
background: rgba(251, 146, 60, 0.15);
|
||||
color: #fb923c;
|
||||
}
|
||||
|
||||
.goal-status-badge-completed {
|
||||
background: rgba(74, 222, 128, 0.15);
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
/* Goal wizard steps */
|
||||
.goal-step {
|
||||
padding: 1rem 1.25rem;
|
||||
border-left: 3px solid rgba(255, 255, 255, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.goal-step-active {
|
||||
border-left-color: #fb923c;
|
||||
background: rgba(251, 146, 60, 0.05);
|
||||
}
|
||||
|
||||
.goal-step-completed {
|
||||
border-left-color: #4ade80;
|
||||
}
|
||||
|
||||
.goal-step-pending {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.glass-button {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
|
||||
@ -28,11 +28,14 @@ export interface StatusInfo {
|
||||
'update-progress': number | null
|
||||
}
|
||||
|
||||
export type UIMode = 'gamer' | 'easy' | 'chat'
|
||||
|
||||
export interface UIData {
|
||||
name: string | null
|
||||
'ack-welcome': string
|
||||
marketplace: UIMarketplaceData
|
||||
theme: string
|
||||
mode?: UIMode
|
||||
}
|
||||
|
||||
export interface UIMarketplaceData {
|
||||
|
||||
32
neode-ui/src/types/goals.ts
Normal file
32
neode-ui/src/types/goals.ts
Normal file
@ -0,0 +1,32 @@
|
||||
// Goal-based workflow types for Easy mode
|
||||
|
||||
export interface GoalDefinition {
|
||||
id: string
|
||||
title: string
|
||||
subtitle: string
|
||||
icon: string
|
||||
category: 'commerce' | 'payments' | 'storage' | 'identity' | 'network' | 'backup'
|
||||
requiredApps: string[]
|
||||
steps: GoalStep[]
|
||||
estimatedTime: string
|
||||
difficulty: 'beginner' | 'intermediate'
|
||||
}
|
||||
|
||||
export interface GoalStep {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
appId?: string
|
||||
action: 'install' | 'configure' | 'verify' | 'info'
|
||||
isAutomatic: boolean
|
||||
}
|
||||
|
||||
export type GoalStatus = 'not-started' | 'in-progress' | 'completed' | 'error'
|
||||
|
||||
export interface GoalProgress {
|
||||
goalId: string
|
||||
status: GoalStatus
|
||||
currentStepIndex: number
|
||||
completedSteps: string[]
|
||||
startedAt?: number
|
||||
}
|
||||
29
neode-ui/src/views/Chat.vue
Normal file
29
neode-ui/src/views/Chat.vue
Normal file
@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center min-h-[60vh]">
|
||||
<div class="glass-card p-12 max-w-lg w-full text-center">
|
||||
<div class="w-16 h-16 mx-auto mb-6 rounded-full bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-2xl font-semibold text-white mb-2">AI Assistant</h2>
|
||||
<p class="text-white/60 mb-8 leading-relaxed">
|
||||
Conversational interface coming soon. Talk to your node, ask questions,
|
||||
and manage everything through natural language.
|
||||
</p>
|
||||
<div class="bg-white/5 border border-white/10 rounded-xl p-4">
|
||||
<input
|
||||
type="text"
|
||||
disabled
|
||||
placeholder="What would you like to do?"
|
||||
class="w-full bg-transparent text-white/30 outline-none placeholder-white/30 cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs text-white/30 mt-4">AIUI integration in development</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Chat mode placeholder — will integrate AIUI here
|
||||
</script>
|
||||
@ -82,7 +82,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav flex-1 min-h-0 space-y-2 p-6 pt-4">
|
||||
<div class="px-6 pt-4 pb-2 shrink-0">
|
||||
<ModeSwitcher />
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav flex-1 min-h-0 space-y-2 p-6 pt-2">
|
||||
<RouterLink
|
||||
v-for="(item, idx) in desktopNavItems"
|
||||
:key="item.path"
|
||||
@ -315,8 +319,12 @@ import { useLoginTransitionStore } from '../stores/loginTransition'
|
||||
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
||||
import OnlineStatusPill from '@/components/OnlineStatusPill.vue'
|
||||
import ControllerIndicator from '@/components/ControllerIndicator.vue'
|
||||
import ModeSwitcher from '@/components/ModeSwitcher.vue'
|
||||
import { useUIModeStore } from '@/stores/uiMode'
|
||||
import { playDashboardLoadOomph } from '@/composables/useLoginSounds'
|
||||
|
||||
const uiMode = useUIModeStore()
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useAppStore()
|
||||
@ -517,76 +525,70 @@ const isOffline = computed(() => store.isOffline)
|
||||
const isRestarting = computed(() => store.isRestarting)
|
||||
const isShuttingDown = computed(() => store.isShuttingDown)
|
||||
|
||||
// Desktop navigation items
|
||||
const desktopNavItems = [
|
||||
{
|
||||
path: '/dashboard',
|
||||
label: 'Home',
|
||||
icon: 'home',
|
||||
},
|
||||
{
|
||||
path: '/dashboard/apps',
|
||||
label: 'My Apps',
|
||||
icon: 'apps',
|
||||
},
|
||||
{
|
||||
path: '/dashboard/marketplace',
|
||||
label: 'App Store',
|
||||
icon: 'marketplace',
|
||||
},
|
||||
{
|
||||
path: '/dashboard/cloud',
|
||||
label: 'Cloud',
|
||||
icon: 'cloud',
|
||||
},
|
||||
{
|
||||
path: '/dashboard/server',
|
||||
label: 'Network',
|
||||
icon: 'server',
|
||||
},
|
||||
{
|
||||
path: '/dashboard/web5',
|
||||
label: 'Web5',
|
||||
icon: 'web5',
|
||||
},
|
||||
{
|
||||
path: '/dashboard/settings',
|
||||
label: 'Settings',
|
||||
icon: 'settings',
|
||||
},
|
||||
// Navigation items — reactive based on UI mode
|
||||
interface NavItem {
|
||||
path: string
|
||||
label: string
|
||||
icon: string
|
||||
isCombined?: boolean
|
||||
}
|
||||
|
||||
const gamerDesktopNav: NavItem[] = [
|
||||
{ path: '/dashboard', label: 'Home', icon: 'home' },
|
||||
{ path: '/dashboard/apps', label: 'My Apps', icon: 'apps' },
|
||||
{ path: '/dashboard/marketplace', label: 'App Store', icon: 'marketplace' },
|
||||
{ path: '/dashboard/cloud', label: 'Cloud', icon: 'cloud' },
|
||||
{ path: '/dashboard/server', label: 'Network', icon: 'server' },
|
||||
{ path: '/dashboard/web5', label: 'Web5', icon: 'web5' },
|
||||
{ path: '/dashboard/settings', label: 'Settings', icon: 'settings' },
|
||||
]
|
||||
|
||||
// Mobile navigation items (Apps and App Store combined)
|
||||
const mobileNavItems = [
|
||||
{
|
||||
path: '/dashboard',
|
||||
label: 'Home',
|
||||
icon: 'home',
|
||||
},
|
||||
{
|
||||
path: '/dashboard/apps',
|
||||
label: 'Apps',
|
||||
icon: 'apps',
|
||||
isCombined: true, // This combines apps and marketplace
|
||||
},
|
||||
{
|
||||
path: '/dashboard/cloud',
|
||||
label: 'Network',
|
||||
icon: 'server',
|
||||
isCombined: true, // This combines server and cloud
|
||||
},
|
||||
{
|
||||
path: '/dashboard/web5',
|
||||
label: 'Web5',
|
||||
icon: 'web5',
|
||||
},
|
||||
{
|
||||
path: '/dashboard/settings',
|
||||
label: 'Settings',
|
||||
icon: 'settings',
|
||||
},
|
||||
const easyDesktopNav: NavItem[] = [
|
||||
{ path: '/dashboard', label: 'Home', icon: 'home' },
|
||||
{ path: '/dashboard/apps', label: 'My Services', icon: 'apps' },
|
||||
{ path: '/dashboard/settings', label: 'Settings', icon: 'settings' },
|
||||
]
|
||||
|
||||
const chatDesktopNav: NavItem[] = [
|
||||
{ path: '/dashboard', label: 'Home', icon: 'home' },
|
||||
{ path: '/dashboard/chat', label: 'Chat', icon: 'chat' },
|
||||
{ path: '/dashboard/apps', label: 'My Apps', icon: 'apps' },
|
||||
{ path: '/dashboard/settings', label: 'Settings', icon: 'settings' },
|
||||
]
|
||||
|
||||
const desktopNavItems = computed(() => {
|
||||
if (uiMode.isEasy) return easyDesktopNav
|
||||
if (uiMode.isChat) return chatDesktopNav
|
||||
return gamerDesktopNav
|
||||
})
|
||||
|
||||
const gamerMobileNav: NavItem[] = [
|
||||
{ path: '/dashboard', label: 'Home', icon: 'home' },
|
||||
{ path: '/dashboard/apps', label: 'Apps', icon: 'apps', isCombined: true },
|
||||
{ path: '/dashboard/cloud', label: 'Network', icon: 'server', isCombined: true },
|
||||
{ path: '/dashboard/web5', label: 'Web5', icon: 'web5' },
|
||||
{ path: '/dashboard/settings', label: 'Settings', icon: 'settings' },
|
||||
]
|
||||
|
||||
const easyMobileNav: NavItem[] = [
|
||||
{ path: '/dashboard', label: 'Home', icon: 'home' },
|
||||
{ path: '/dashboard/apps', label: 'Services', icon: 'apps' },
|
||||
{ path: '/dashboard/settings', label: 'Settings', icon: 'settings' },
|
||||
]
|
||||
|
||||
const chatMobileNav: NavItem[] = [
|
||||
{ path: '/dashboard', label: 'Home', icon: 'home' },
|
||||
{ path: '/dashboard/chat', label: 'Chat', icon: 'chat' },
|
||||
{ path: '/dashboard/apps', label: 'Apps', icon: 'apps' },
|
||||
{ path: '/dashboard/settings', label: 'Settings', icon: 'settings' },
|
||||
]
|
||||
|
||||
const mobileNavItems = computed(() => {
|
||||
if (uiMode.isEasy) return easyMobileNav
|
||||
if (uiMode.isChat) return chatMobileNav
|
||||
return gamerMobileNav
|
||||
})
|
||||
|
||||
function getIconPath(iconName: string): string[] {
|
||||
const icons: Record<string, string[]> = {
|
||||
home: ['M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6'],
|
||||
@ -595,6 +597,7 @@ function getIconPath(iconName: string): string[] {
|
||||
cloud: ['M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z'],
|
||||
server: ['M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01'],
|
||||
web5: ['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'],
|
||||
chat: ['M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z'],
|
||||
settings: [
|
||||
'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z',
|
||||
'M15 12a3 3 0 11-6 0 3 3 0 016 0z',
|
||||
|
||||
266
neode-ui/src/views/GoalDetail.vue
Normal file
266
neode-ui/src/views/GoalDetail.vue
Normal file
@ -0,0 +1,266 @@
|
||||
<template>
|
||||
<div class="pb-6">
|
||||
<!-- Back button -->
|
||||
<button @click="goBack" class="flex items-center gap-2 text-white/60 hover:text-white mb-6 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 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span>Back to Goals</span>
|
||||
</button>
|
||||
|
||||
<!-- Goal not found -->
|
||||
<div v-if="!goal" class="glass-card p-12 text-center">
|
||||
<p class="text-white/70">Goal not found.</p>
|
||||
</div>
|
||||
|
||||
<!-- Goal wizard -->
|
||||
<template v-else>
|
||||
<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)]">{{ goal.title }}</h1>
|
||||
<p class="text-white/70">{{ goal.subtitle }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm text-white/60">Step {{ currentStepDisplay }} of {{ goal.steps.length }}</span>
|
||||
<span class="goal-status-badge" :class="statusBadgeClass">{{ statusLabel }}</span>
|
||||
</div>
|
||||
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-500 ease-out"
|
||||
:class="overallStatus === 'completed' ? 'bg-green-400' : 'bg-orange-400'"
|
||||
:style="{ width: `${progressPercent}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sovereignty patience message for bitcoin-dependent goals -->
|
||||
<div
|
||||
v-if="showSyncMessage"
|
||||
class="glass-card p-6 mb-6 border-l-4 border-orange-400"
|
||||
>
|
||||
<h3 class="text-lg font-semibold text-white mb-1">Sovereignty takes a little patience</h3>
|
||||
<p class="text-white/60 text-sm leading-relaxed">
|
||||
Your Bitcoin node is syncing the entire blockchain so you don't have to trust anyone else.
|
||||
This takes 2-3 days on first run. Meanwhile, you can explore your node, set up your identity, or back up your keys.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Steps -->
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="(step, idx) in goal.steps"
|
||||
:key="step.id"
|
||||
class="glass-card p-0 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="goal-step"
|
||||
:class="{
|
||||
'goal-step-completed': isStepCompleted(step),
|
||||
'goal-step-active': idx === activeStepIndex && overallStatus !== 'completed',
|
||||
'goal-step-pending': idx > activeStepIndex && !isStepCompleted(step),
|
||||
}"
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<!-- Step indicator -->
|
||||
<div class="mt-0.5 shrink-0">
|
||||
<div v-if="isStepCompleted(step)" class="w-6 h-6 rounded-full bg-green-500/20 flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div v-else-if="idx === activeStepIndex && isInstalling" class="w-6 h-6 rounded-full bg-orange-500/20 flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-orange-400 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div v-else class="w-6 h-6 rounded-full bg-white/10 flex items-center justify-center">
|
||||
<span class="text-xs text-white/40 font-medium">{{ idx + 1 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-base font-semibold text-white/90 mb-1">{{ step.title }}</h3>
|
||||
<p class="text-sm text-white/55 leading-relaxed">{{ step.description }}</p>
|
||||
|
||||
<!-- Action button for active step -->
|
||||
<div v-if="idx === activeStepIndex && overallStatus !== 'completed'" class="mt-4">
|
||||
<button
|
||||
v-if="step.action === 'install' && step.appId && !isAppInstalled(step.appId)"
|
||||
@click="installApp(step)"
|
||||
:disabled="isInstalling"
|
||||
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium"
|
||||
>
|
||||
{{ isInstalling ? 'Installing...' : `Install ${step.title.replace('Install ', '')}` }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="step.action === 'configure'"
|
||||
@click="openConfigureStep(step)"
|
||||
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium"
|
||||
>
|
||||
Open & Configure
|
||||
</button>
|
||||
<button
|
||||
v-else-if="step.action === 'info'"
|
||||
@click="completeInfoStep(step)"
|
||||
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium"
|
||||
>
|
||||
I've Done This
|
||||
</button>
|
||||
<button
|
||||
v-else-if="isStepCompleted(step) || isAppInstalled(step.appId || '')"
|
||||
disabled
|
||||
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium opacity-50"
|
||||
>
|
||||
Complete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Completion -->
|
||||
<div v-if="overallStatus === 'completed'" class="glass-card p-8 mt-6 text-center">
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-green-500/20 flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-white mb-2">All Set!</h2>
|
||||
<p class="text-white/60 mb-6">{{ goal.title }} is ready to go.</p>
|
||||
<RouterLink to="/dashboard/apps" class="glass-button rounded-lg px-6 py-3 font-medium">
|
||||
View My Services
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useGoalStore } from '@/stores/goals'
|
||||
import { getGoalById } from '@/data/goals'
|
||||
import type { GoalStep } from '@/types/goals'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
const goalStore = useGoalStore()
|
||||
|
||||
const goalId = computed(() => route.params.goalId as string)
|
||||
const goal = computed(() => getGoalById(goalId.value))
|
||||
|
||||
const isInstalling = ref(false)
|
||||
|
||||
const overallStatus = computed(() => goalStore.getGoalStatus(goalId.value))
|
||||
|
||||
const completedSteps = computed(() => {
|
||||
if (!goal.value) return new Set<string>()
|
||||
const completed = new Set<string>()
|
||||
for (const step of goal.value.steps) {
|
||||
if (step.appId && isAppInstalled(step.appId)) {
|
||||
completed.add(step.id)
|
||||
}
|
||||
if (goalStore.progress[goalId.value]?.completedSteps.includes(step.id)) {
|
||||
completed.add(step.id)
|
||||
}
|
||||
}
|
||||
return completed
|
||||
})
|
||||
|
||||
const activeStepIndex = computed(() => {
|
||||
if (!goal.value) return 0
|
||||
const steps = goal.value.steps
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const step = steps[i]
|
||||
if (step && !completedSteps.value.has(step.id)) return i
|
||||
}
|
||||
return steps.length - 1
|
||||
})
|
||||
|
||||
const currentStepDisplay = computed(() => Math.min(activeStepIndex.value + 1, goal.value?.steps.length || 1))
|
||||
|
||||
const progressPercent = computed(() => {
|
||||
if (!goal.value) return 0
|
||||
return Math.round((completedSteps.value.size / goal.value.steps.length) * 100)
|
||||
})
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
if (overallStatus.value === 'completed') return 'Completed'
|
||||
if (overallStatus.value === 'in-progress') return 'In Progress'
|
||||
return 'Not Started'
|
||||
})
|
||||
|
||||
const statusBadgeClass = computed(() => {
|
||||
if (overallStatus.value === 'completed') return 'goal-status-badge-completed'
|
||||
if (overallStatus.value === 'in-progress') return 'goal-status-badge-in-progress'
|
||||
return 'goal-status-badge-not-started'
|
||||
})
|
||||
|
||||
const showSyncMessage = computed(() => {
|
||||
if (!goal.value) return false
|
||||
const hasBitcoin = goal.value.requiredApps.includes('bitcoin-knots')
|
||||
const bitcoinNotRunning = !isAppRunning('bitcoin-knots')
|
||||
return hasBitcoin && bitcoinNotRunning && overallStatus.value !== 'completed'
|
||||
})
|
||||
|
||||
function isStepCompleted(step: GoalStep): boolean {
|
||||
return completedSteps.value.has(step.id)
|
||||
}
|
||||
|
||||
function isAppInstalled(appId: string): boolean {
|
||||
return Object.keys(appStore.packages).some((pkgId) => pkgId === appId)
|
||||
}
|
||||
|
||||
function isAppRunning(appId: string): boolean {
|
||||
return Object.entries(appStore.packages).some(
|
||||
([pkgId, pkg]) => pkgId === appId && pkg.state === 'running',
|
||||
)
|
||||
}
|
||||
|
||||
async function installApp(step: GoalStep) {
|
||||
if (!step.appId) return
|
||||
isInstalling.value = true
|
||||
|
||||
// Start goal tracking if not already started
|
||||
if (!goalStore.progress[goalId.value]) {
|
||||
goalStore.startGoal(goalId.value)
|
||||
}
|
||||
|
||||
try {
|
||||
await appStore.installPackage(step.appId, '', 'latest')
|
||||
goalStore.completeStep(goalId.value, step.id)
|
||||
} catch (err) {
|
||||
console.error('[GoalDetail] Install failed:', err)
|
||||
} finally {
|
||||
isInstalling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openConfigureStep(step: GoalStep) {
|
||||
// Mark step as complete when user acknowledges they've configured
|
||||
goalStore.completeStep(goalId.value, step.id)
|
||||
// If there's an app to open, navigate to it
|
||||
if (step.appId) {
|
||||
router.push(`/dashboard/apps/${step.appId}`)
|
||||
}
|
||||
}
|
||||
|
||||
function completeInfoStep(step: GoalStep) {
|
||||
if (!goalStore.progress[goalId.value]) {
|
||||
goalStore.startGoal(goalId.value)
|
||||
}
|
||||
goalStore.completeStep(goalId.value, step.id)
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push('/dashboard')
|
||||
}
|
||||
</script>
|
||||
@ -11,8 +11,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section Overviews - 2advanced staggered animation (hidden until typing starts, then animate with typing) -->
|
||||
<!-- Easy Mode: Goal-based cards -->
|
||||
<EasyHome
|
||||
v-if="uiMode.isEasy"
|
||||
:show="!showWelcomeBlock || animateCards"
|
||||
:animate="animateCards"
|
||||
/>
|
||||
|
||||
<!-- Pro Mode: Section overview cards -->
|
||||
<div
|
||||
v-else-if="uiMode.isGamer"
|
||||
class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8 transition-opacity duration-300"
|
||||
:class="{ 'opacity-0 pointer-events-none': showWelcomeBlock && !animateCards }"
|
||||
>
|
||||
@ -219,43 +227,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions - Hidden for now -->
|
||||
<!--
|
||||
<div class="path-option-card cursor-default px-6 py-6">
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-4">Quick Actions</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<!-- Quick Start Goals - shown in Pro mode below the overview cards -->
|
||||
<div v-if="uiMode.isGamer" class="path-option-card cursor-default px-6 py-6">
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-2">Quick Start Goals</h2>
|
||||
<p class="text-sm text-white/60 mb-4">Not sure where to start? Try a guided setup.</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<RouterLink
|
||||
to="/dashboard/marketplace"
|
||||
v-for="goal in topGoals"
|
||||
:key="goal.id"
|
||||
:to="`/dashboard/goals/${goal.id}`"
|
||||
class="path-action-button path-action-button--continue flex items-center justify-center gap-3"
|
||||
>
|
||||
<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="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
|
||||
</svg>
|
||||
<span>Browse App Store</span>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink
|
||||
to="/dashboard/apps"
|
||||
class="path-action-button path-action-button--continue flex items-center justify-center gap-3"
|
||||
>
|
||||
<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="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
<span>Manage My Apps</span>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink
|
||||
to="/dashboard/server"
|
||||
class="path-action-button path-action-button--continue flex items-center justify-center gap-3"
|
||||
>
|
||||
<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="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
<span>Server Settings</span>
|
||||
<span>{{ goal.title }}</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
-->
|
||||
|
||||
<!-- Chat Mode: redirect to Chat view -->
|
||||
<div v-if="uiMode.isChat" class="flex flex-col items-center justify-center min-h-[40vh]">
|
||||
<RouterLink to="/dashboard/chat" class="glass-button rounded-lg px-8 py-4 text-lg font-medium">
|
||||
Open AI Assistant
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -264,8 +257,14 @@ import { computed, ref, watch, onBeforeUnmount } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { useLoginTransitionStore } from '../stores/loginTransition'
|
||||
import { useUIModeStore } from '@/stores/uiMode'
|
||||
import { PackageState } from '../types/api'
|
||||
import { playTypingSound } from '@/composables/useLoginSounds'
|
||||
import { GOALS } from '@/data/goals'
|
||||
import EasyHome from '@/components/EasyHome.vue'
|
||||
|
||||
const uiMode = useUIModeStore()
|
||||
const topGoals = GOALS.slice(0, 3)
|
||||
|
||||
const store = useAppStore()
|
||||
const loginTransition = useLoginTransitionStore()
|
||||
|
||||
@ -186,6 +186,37 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Interface Mode Section -->
|
||||
<div class="path-option-card cursor-default px-6 py-6 mb-6">
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-2">Interface Mode</h2>
|
||||
<p class="text-sm text-white/60 mb-6">Choose how you want to interact with your node.</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<button
|
||||
v-for="m in interfaceModes"
|
||||
:key="m.id"
|
||||
@click="uiMode.setMode(m.id)"
|
||||
class="path-option-card text-left p-5"
|
||||
:class="{ 'path-option-card--selected': uiMode.mode === m.id }"
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<svg class="w-6 h-6 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
v-for="(path, index) in m.iconPaths"
|
||||
:key="index"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
:d="path"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="text-lg font-semibold text-white/96">{{ m.label }}</h3>
|
||||
</div>
|
||||
<p class="text-sm text-white/60 leading-relaxed">{{ m.description }}</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Section -->
|
||||
<div class="path-option-card cursor-default px-6 py-6">
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-4">System</h2>
|
||||
@ -204,12 +235,36 @@
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { useUIModeStore } from '@/stores/uiMode'
|
||||
import ControllerIndicator from '@/components/ControllerIndicator.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||
import type { UIMode } from '@/types/api'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useAppStore()
|
||||
const uiMode = useUIModeStore()
|
||||
|
||||
const interfaceModes: { id: UIMode; label: string; description: string; iconPaths: string[] }[] = [
|
||||
{
|
||||
id: 'easy',
|
||||
label: 'Easy',
|
||||
description: 'Goal-based interface. Choose what you want to do, and the system handles the rest.',
|
||||
iconPaths: ['M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z'],
|
||||
},
|
||||
{
|
||||
id: 'gamer',
|
||||
label: 'Pro',
|
||||
description: 'Full control over all services. Configure everything manually with all technical details.',
|
||||
iconPaths: ['M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z', 'M15 12a3 3 0 11-6 0 3 3 0 016 0z'],
|
||||
},
|
||||
{
|
||||
id: 'chat',
|
||||
label: 'Chat',
|
||||
description: 'Conversational AI interface. Manage your node through natural language. Coming soon.',
|
||||
iconPaths: ['M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z'],
|
||||
},
|
||||
]
|
||||
|
||||
const serverName = computed(() => store.serverName)
|
||||
const version = computed(() => store.serverInfo?.version || '0.0.0')
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user