177 lines
8.2 KiB
Vue
177 lines
8.2 KiB
Vue
|
|
<template>
|
||
|
|
<div
|
||
|
|
data-controller-container
|
||
|
|
:data-controller-launch="canLaunch(pkg) ? '' : undefined"
|
||
|
|
tabindex="0"
|
||
|
|
role="link"
|
||
|
|
class="glass-card p-6 transition-all hover:-translate-y-1 cursor-pointer relative min-w-0 overflow-hidden"
|
||
|
|
:class="{ 'card-stagger': showStagger }"
|
||
|
|
:style="{ '--stagger-index': index }"
|
||
|
|
@click="$emit('goToApp', id)"
|
||
|
|
@keydown.enter="$emit('goToApp', id)"
|
||
|
|
>
|
||
|
|
<!-- Uninstalling overlay -->
|
||
|
|
<div
|
||
|
|
v-if="isUninstalling"
|
||
|
|
class="absolute inset-0 z-20 flex items-center justify-center bg-black/70 backdrop-blur-sm rounded-xl"
|
||
|
|
>
|
||
|
|
<div class="flex items-center gap-3 text-white/90">
|
||
|
|
<svg class="animate-spin h-5 w-5" 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>
|
||
|
|
<span class="text-sm font-medium">{{ t('common.uninstalling') }}...</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Uninstall Icon (not for web-only apps) -->
|
||
|
|
<button
|
||
|
|
v-if="!isWebOnly && !isUninstalling"
|
||
|
|
@click.stop="$emit('showUninstall', id, pkg)"
|
||
|
|
class="absolute top-4 right-4 p-2 rounded-lg text-white/60 hover:text-red-400 hover:bg-red-500/20 transition-colors z-10"
|
||
|
|
:aria-label="`${t('common.uninstall')} ${pkg.manifest?.title || id}`"
|
||
|
|
:title="t('common.uninstall')"
|
||
|
|
>
|
||
|
|
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||
|
|
</svg>
|
||
|
|
</button>
|
||
|
|
|
||
|
|
<div class="flex items-start gap-4">
|
||
|
|
<img
|
||
|
|
:src="pkg['static-files']?.icon || `/assets/img/app-icons/${id}.png`"
|
||
|
|
:alt="pkg.manifest?.title || String(id)"
|
||
|
|
class="w-16 h-16 rounded-lg object-cover bg-white/10"
|
||
|
|
@error="handleImageError"
|
||
|
|
/>
|
||
|
|
<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">
|
||
|
|
{{ pkg.manifest?.description?.short || '' }}
|
||
|
|
</p>
|
||
|
|
<div class="flex items-center gap-2">
|
||
|
|
<span
|
||
|
|
class="inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium"
|
||
|
|
:class="getStatusClass(pkg.state, pkg.health)"
|
||
|
|
>
|
||
|
|
<svg
|
||
|
|
v-if="isTransitioning"
|
||
|
|
class="animate-spin h-3 w-3"
|
||
|
|
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>
|
||
|
|
<span v-if="pkg.state === 'running' && pkg.health === 'unhealthy'" class="w-1.5 h-1.5 rounded-full bg-orange-400 animate-pulse"></span>
|
||
|
|
{{ getStatusLabel(pkg.state, pkg.health) }}
|
||
|
|
</span>
|
||
|
|
<span class="text-xs text-white/50">
|
||
|
|
v{{ pkg.manifest.version }}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Quick Actions -->
|
||
|
|
<div v-if="!isUninstalling" class="mt-4 flex gap-2">
|
||
|
|
<button
|
||
|
|
v-if="canLaunch(pkg)"
|
||
|
|
data-controller-launch-btn
|
||
|
|
@click.stop="$emit('launch', id)"
|
||
|
|
class="flex-1 px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium flex items-center justify-center gap-1.5"
|
||
|
|
>
|
||
|
|
{{ t('common.launch') }}
|
||
|
|
<svg v-if="opensInTab(id)" class="w-3.5 h-3.5 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
v-if="!isWebOnly && !isLoading && (pkg.state === 'stopped' || pkg.state === 'exited')"
|
||
|
|
@click.stop="$emit('start', id)"
|
||
|
|
class="flex-1 px-4 py-2 glass-button glass-button-success rounded-lg text-sm font-medium flex items-center justify-center gap-2"
|
||
|
|
>
|
||
|
|
<span>{{ pkg.state === 'exited' ? 'Restart' : t('common.start') }}</span>
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
v-if="!isWebOnly && isLoading && (pkg.state === 'stopped' || pkg.state === 'exited' || pkg.state === 'starting')"
|
||
|
|
disabled
|
||
|
|
class="flex-1 px-4 py-2 glass-button glass-button-success rounded-lg text-sm font-medium opacity-50 cursor-not-allowed flex items-center justify-center gap-2"
|
||
|
|
>
|
||
|
|
<svg class="animate-spin h-4 w-4" aria-hidden="true" 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>
|
||
|
|
<span>{{ t('common.starting') }}</span>
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
v-if="!isWebOnly && !isLoading && (pkg.state === 'running' || pkg.state === 'starting')"
|
||
|
|
@click.stop="$emit('stop', id)"
|
||
|
|
class="flex-1 px-4 py-2 bg-yellow-500/20 border border-yellow-500/40 rounded-lg text-yellow-200 text-sm font-medium hover:bg-yellow-500/30 transition-colors flex items-center justify-center gap-2"
|
||
|
|
>
|
||
|
|
<span>{{ t('common.stop') }}</span>
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
v-if="!isWebOnly && !isLoading && (pkg.state === 'running' || pkg.state === 'starting')"
|
||
|
|
@click.stop="$emit('restart', id)"
|
||
|
|
class="px-2.5 py-2 glass-button glass-button-sm rounded-lg flex items-center justify-center"
|
||
|
|
:title="t('common.restart')"
|
||
|
|
>
|
||
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||
|
|
</svg>
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
v-if="!isWebOnly && isLoading && (pkg.state === 'running' || pkg.state === 'starting' || pkg.state === 'stopping')"
|
||
|
|
disabled
|
||
|
|
class="flex-1 px-4 py-2 bg-yellow-500/20 border border-yellow-500/40 rounded-lg text-yellow-200 text-sm font-medium opacity-50 cursor-not-allowed flex items-center justify-center gap-2"
|
||
|
|
>
|
||
|
|
<svg class="animate-spin h-4 w-4" aria-hidden="true" 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>
|
||
|
|
<span>{{ t('common.stopping') }}</span>
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<script setup lang="ts">
|
||
|
|
import { computed } from 'vue'
|
||
|
|
import { useI18n } from 'vue-i18n'
|
||
|
|
import type { PackageDataEntry } from '@/types/api'
|
||
|
|
import {
|
||
|
|
isWebOnlyApp, opensInTab, canLaunch,
|
||
|
|
getStatusClass, getStatusLabel, handleImageError,
|
||
|
|
} from './appsConfig'
|
||
|
|
|
||
|
|
const { t } = useI18n()
|
||
|
|
|
||
|
|
const props = defineProps<{
|
||
|
|
id: string
|
||
|
|
pkg: PackageDataEntry
|
||
|
|
index: number
|
||
|
|
showStagger: boolean
|
||
|
|
isLoading: boolean
|
||
|
|
isUninstalling: boolean
|
||
|
|
}>()
|
||
|
|
|
||
|
|
defineEmits<{
|
||
|
|
goToApp: [id: string]
|
||
|
|
launch: [id: string]
|
||
|
|
start: [id: string]
|
||
|
|
stop: [id: string]
|
||
|
|
restart: [id: string]
|
||
|
|
showUninstall: [id: string, pkg: PackageDataEntry]
|
||
|
|
}>()
|
||
|
|
|
||
|
|
const isWebOnly = computed(() => isWebOnlyApp(props.id))
|
||
|
|
|
||
|
|
const isTransitioning = computed(() => {
|
||
|
|
const s = props.pkg.state
|
||
|
|
const h = props.pkg.health
|
||
|
|
return s === 'starting' || s === 'installing' || s === 'stopping' || s === 'restarting' || (s === 'running' && h === 'starting')
|
||
|
|
})
|
||
|
|
</script>
|