archy/neode-ui/src/views/GoalDetail.vue
Dorian 9a81116ca2 fix: polish UX error handling across views (FINAL-01)
- AppDetails: replace alert() with dismissible toast, add error feedback
  for start/stop/restart/uninstall actions
- GoalDetail: add error toast for install failures instead of silent catch
- Apps: add loading skeleton when WebSocket data hasn't arrived yet
- Add appDetails.noLaunchUrl i18n key

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:33:42 +00:00

310 lines
12 KiB
Vue

<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>{{ t('goalDetail.backToGoals') }}</span>
</button>
<!-- Goal not found -->
<div v-if="!goal" class="glass-card p-12 text-center">
<p class="text-white/70">{{ t('goalDetail.notFound') }}</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">{{ t('goalDetail.stepOf', { current: currentStepDisplay, total: 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">{{ t('goalDetail.syncTitle') }}</h3>
<p class="text-white/60 text-sm leading-relaxed">
{{ t('goalDetail.syncMessage') }}
</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 ? t('common.installing') : t('goalDetail.installApp', { name: 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"
>
{{ t('goalDetail.openAndConfigure') }}
</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"
>
{{ t('goalDetail.iveDoneThis') }}
</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"
>
{{ t('goalDetail.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">{{ t('goalDetail.allSet') }}</h2>
<p class="text-white/60 mb-6">{{ t('goalDetail.goalReady', { title: goal.title }) }}</p>
<RouterLink to="/dashboard/apps" class="glass-button rounded-lg px-6 py-3 font-medium">
{{ t('goalDetail.viewMyServices') }}
</RouterLink>
</div>
</template>
<!-- Action error toast -->
<Transition name="fade">
<div v-if="actionError" class="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 max-w-md w-full px-4" role="alert" aria-live="assertive">
<div class="bg-red-500/20 border border-red-500/40 backdrop-blur-sm rounded-lg px-4 py-3 text-red-200 text-sm flex items-center justify-between gap-3">
<span>{{ actionError }}</span>
<button @click="actionError = ''" class="text-red-300 hover:text-white shrink-0">&times;</button>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter, RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useGoalStore } from '@/stores/goals'
import { getGoalById } from '@/data/goals'
import type { GoalStep } from '@/types/goals'
const { t } = useI18n()
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 actionError = ref('')
let errorTimer: ReturnType<typeof setTimeout> | undefined
function showActionError(msg: string) {
actionError.value = msg
if (errorTimer) clearTimeout(errorTimer)
errorTimer = setTimeout(() => { actionError.value = '' }, 5000)
}
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 t('goalDetail.completed')
if (overallStatus.value === 'in-progress') return t('goalDetail.inProgress')
return t('goalDetail.notStarted')
})
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)
}
/** App ID aliases — backend may register under variant names */
const APP_ALIASES: Record<string, string[]> = {
immich: ['immich-server', 'immich-app', 'immich_server'],
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
'bitcoin-knots': ['bitcoin', 'bitcoin-core'],
}
function matchesAppId(pkgId: string, appId: string): boolean {
if (pkgId === appId) return true
const aliases = APP_ALIASES[appId]
return aliases ? aliases.includes(pkgId) : false
}
function isAppInstalled(appId: string): boolean {
return Object.keys(appStore.packages).some((pkgId) => matchesAppId(pkgId, appId))
}
function isAppRunning(appId: string): boolean {
return Object.entries(appStore.packages).some(
([pkgId, pkg]) => matchesAppId(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) {
showActionError(`Install failed: ${err instanceof Error ? err.message : 'Unknown error'}`)
} 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>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>