archy/neode-ui/src/views/Apps.vue

264 lines
10 KiB
Vue
Raw Normal View History

2026-01-24 22:59:20 +00:00
<template>
<div class="pb-6">
<!-- Nav header -- tabs + categories + search -->
<div class="mb-4">
<!-- Desktop: page tabs + category tabs + search -->
<div class="hidden md:flex items-center gap-4">
<div class="mode-switcher flex-shrink-0">
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'apps' }" @click="activeTab = 'apps'; router.replace({ query: {} })">My Apps</button>
<RouterLink to="/dashboard/discover" class="mode-switcher-btn">App Store</RouterLink>
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'services' }" @click="activeTab = 'services'; router.replace({ query: { tab: 'services' } })">Services</button>
</div>
<div v-if="activeTab === 'apps' && categoriesWithApps.length > 1" class="mode-switcher flex-shrink-0">
<button
v-for="category in categoriesWithApps"
:key="category.id"
@click="selectedCategory = category.id"
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': selectedCategory === category.id }"
>{{ category.name }}</button>
</div>
<input
v-model="searchQuery"
type="text"
:placeholder="t('apps.searchPlaceholder')"
:aria-label="t('apps.searchLabel')"
class="flex-1 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>
2026-01-24 22:59:20 +00:00
<!-- Mobile: search only (tabs handled by Dashboard.vue header) -->
<div class="md:hidden">
<input
v-model="searchQuery"
type="text"
:placeholder="t('apps.searchPlaceholder')"
:aria-label="t('apps.searchLabel')"
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
/>
</div>
</div>
<!-- Loading Skeleton -->
<div v-if="!store.isConnected && sortedPackageEntries.length === 0 && !connectionError" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 pb-6">
<div v-for="i in 3" :key="i" class="glass-card p-6 animate-pulse">
<div class="flex items-start gap-4">
<div class="w-16 h-16 rounded-lg bg-white/10"></div>
<div class="flex-1">
<div class="h-5 w-32 bg-white/10 rounded mb-2"></div>
<div class="h-4 w-48 bg-white/5 rounded mb-3"></div>
<div class="h-6 w-20 bg-white/5 rounded"></div>
</div>
</div>
<div class="mt-4 flex gap-2">
<div class="flex-1 h-9 bg-white/5 rounded-lg"></div>
</div>
</div>
</div>
<!-- Connection Error -->
<div v-else-if="connectionError && sortedPackageEntries.length === 0" class="text-center py-12 pb-6">
<div class="glass-card p-8 max-w-md mx-auto">
<div class="alert-error mb-4">{{ connectionError }}</div>
<button
@click="connectionError = ''; store.connectWebSocket()"
class="glass-button px-6 py-3 rounded-lg font-medium"
>
Retry Connection
</button>
</div>
</div>
<!-- Empty State -->
<div v-else-if="sortedPackageEntries.length === 0 && !searchQuery" class="text-center py-16 pb-6">
2026-01-24 22:59:20 +00:00
<div class="glass-card p-12 max-w-md mx-auto">
<svg class="w-16 h-16 mx-auto text-white/40 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
<h3 class="text-xl font-semibold text-white mb-2">{{ t('apps.noAppsTitle') }}</h3>
<p class="text-white/70 mb-6">{{ t('apps.noAppsMessage') }}</p>
2026-01-24 22:59:20 +00:00
<RouterLink
to="/dashboard/marketplace"
class="inline-block glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 hover:border-white/30"
>
{{ t('apps.browseAppStore') }}
2026-01-24 22:59:20 +00:00
</RouterLink>
</div>
</div>
<!-- No Results -->
<div v-if="filteredPackageEntries.length === 0 && searchQuery" class="text-center py-12">
<p class="text-white/70">{{ t('apps.noResults', { query: searchQuery }) }}</p>
</div>
<!-- Apps Grid -->
2026-01-24 22:59:20 +00:00
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 pb-6">
<AppCard
v-for="([id, pkg], index) in filteredPackageEntries"
2026-01-24 22:59:20 +00:00
:key="id"
:id="id as string"
:pkg="pkg"
:index="index"
:show-stagger="showStagger"
:is-loading="!!actions.loadingActions.value[id as string]"
:is-uninstalling="actions.uninstallingApps.value.has(id as string)"
@go-to-app="goToApp"
@launch="launchApp"
@start="actions.startApp"
@stop="actions.stopApp"
@restart="actions.restartApp"
@show-uninstall="showUninstallModal"
/>
2026-01-24 22:59:20 +00:00
</div>
<AppsUninstallModal
:show="uninstallModal.show"
:app-title="uninstallModal.appTitle"
:uninstalling="actions.uninstalling.value"
@close="closeUninstallModal"
@confirm="onConfirmUninstall"
/>
<!-- Action error toast -->
<Transition name="fade">
<div v-if="actions.actionError.value" 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="alert-error backdrop-blur-sm rounded-lg px-4 py-3 text-sm flex items-center justify-between gap-3">
<span>{{ actions.actionError.value }}</span>
<button @click="actions.actionError.value = ''" :aria-label="t('apps.dismissError')" class="text-red-300 hover:text-white shrink-0">&times;</button>
</div>
</div>
</Transition>
2026-01-24 22:59:20 +00:00
</div>
</template>
<script lang="ts">
// Module-level -- persists across component unmount/remount within same session
let appsAnimationDone = false
</script>
2026-01-24 22:59:20 +00:00
<script setup lang="ts">
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { useRouter, useRoute, RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useAppLauncherStore } from '@/stores/appLauncher'
import type { PackageDataEntry } from '@/types/api'
import AppCard from './apps/AppCard.vue'
import AppsUninstallModal from './apps/AppsUninstallModal.vue'
import { useAppsActions } from './apps/useAppsActions'
import {
isServiceContainer, isWebOnlyApp, getAppCategory,
WEB_ONLY_APPS, buildAllCategories, useCategoriesWithApps,
} from './apps/appsConfig'
2026-01-24 22:59:20 +00:00
const { t } = useI18n()
2026-01-24 22:59:20 +00:00
const router = useRouter()
const route = useRoute()
2026-01-24 22:59:20 +00:00
const store = useAppStore()
const actions = useAppsActions()
2026-01-24 22:59:20 +00:00
// Only stagger-animate on first mount
const showStagger = !appsAnimationDone
// Tabs
const activeTab = ref<'apps' | 'services'>(
route.query.tab === 'services' ? 'services' : 'apps'
)
// Search (debounced)
const searchQuery = ref('')
const debouncedSearchQuery = ref('')
let searchDebounceTimer: ReturnType<typeof setTimeout> | undefined
watch(searchQuery, (val) => {
clearTimeout(searchDebounceTimer)
searchDebounceTimer = setTimeout(() => { debouncedSearchQuery.value = val }, 150)
})
onBeforeUnmount(() => { clearTimeout(searchDebounceTimer) })
// Category filter
const selectedCategory = ref('all')
const ALL_CATEGORIES = computed(() => buildAllCategories(t))
// Merge real packages from store with web-only app bookmarks
const packages = computed(() => {
const realPackages = store.packages || {}
return { ...WEB_ONLY_APPS, ...realPackages }
})
const categoriesWithApps = useCategoriesWithApps(packages, ALL_CATEGORIES)
// Connection error state
const connectionError = ref('')
let connectionTimer: ReturnType<typeof setTimeout> | undefined
onMounted(() => {
appsAnimationDone = true
if (!store.isConnected) {
connectionTimer = setTimeout(() => {
if (!store.isConnected && sortedPackageEntries.value.length === 0) {
connectionError.value = 'Unable to connect to server. Check that the backend is running.'
}
}, 15000)
}
})
onBeforeUnmount(() => {
if (connectionTimer) clearTimeout(connectionTimer)
2026-01-24 22:59:20 +00:00
})
// Sorted entries: web-only first, then alphabetical by title
const sortedPackageEntries = computed(() => {
const entries = Object.entries(packages.value)
const filtered = entries.filter(([id, pkg]) => {
const isSvc = isServiceContainer(id)
if (activeTab.value === 'services' ? !isSvc : isSvc) return false
if (activeTab.value === 'apps' && selectedCategory.value !== 'all') {
return getAppCategory(id, pkg) === selectedCategory.value
}
return true
})
return filtered.sort(([idA, a], [idB, b]) => {
const aWeb = isWebOnlyApp(idA) ? 0 : 1
const bWeb = isWebOnlyApp(idB) ? 0 : 1
if (aWeb !== bWeb) return aWeb - bWeb
return (a.manifest?.title ?? '').localeCompare(b.manifest?.title ?? '', undefined, { sensitivity: 'base' })
})
})
const filteredPackageEntries = computed(() => {
if (!debouncedSearchQuery.value) return sortedPackageEntries.value
const q = debouncedSearchQuery.value.toLowerCase()
return sortedPackageEntries.value.filter(([id, pkg]) =>
(pkg.manifest?.title ?? '').toLowerCase().includes(q) ||
(pkg.manifest?.description?.short ?? '').toLowerCase().includes(q) ||
id.toLowerCase().includes(q)
)
})
// Uninstall modal
const uninstallModal = ref({ show: false, appId: '', appTitle: '' })
function showUninstallModal(id: string, pkg: PackageDataEntry) {
uninstallModal.value = { show: true, appId: id, appTitle: pkg.manifest.title }
2026-01-24 22:59:20 +00:00
}
function closeUninstallModal() {
uninstallModal.value.show = false
2026-01-24 22:59:20 +00:00
}
async function onConfirmUninstall() {
const { appId } = uninstallModal.value
uninstallModal.value.show = false
await actions.confirmUninstall(appId)
}
2026-01-24 22:59:20 +00:00
function goToApp(id: string) {
router.push(`/dashboard/apps/${id}`).catch(() => {})
2026-01-24 22:59:20 +00:00
}
function launchApp(id: string) {
useAppLauncherStore().openSession(id)
2026-01-24 22:59:20 +00:00
}
</script>