|
|
|
|
@@ -2,15 +2,6 @@
|
|
|
|
|
import { ref, computed, onMounted } from 'vue'
|
|
|
|
|
import { fetchModules, manageModule } from '@/services/moduleService'
|
|
|
|
|
import type { Module, ModuleAction } from '@/types/module'
|
|
|
|
|
import {
|
|
|
|
|
mdiPackageVariant,
|
|
|
|
|
mdiDownload,
|
|
|
|
|
mdiTrashCan,
|
|
|
|
|
mdiCheckCircle,
|
|
|
|
|
mdiCloseCircle,
|
|
|
|
|
mdiUpdate,
|
|
|
|
|
mdiLoading,
|
|
|
|
|
} from '@mdi/js'
|
|
|
|
|
|
|
|
|
|
const modules = ref<Module[]>([])
|
|
|
|
|
const loading = ref(true)
|
|
|
|
|
@@ -20,31 +11,43 @@ const snackbar = ref(false)
|
|
|
|
|
const snackbarText = ref('')
|
|
|
|
|
const snackbarColor = ref('success')
|
|
|
|
|
const search = ref('')
|
|
|
|
|
const filterStatus = ref<'all' | 'installed' | 'not-installed' | 'enabled' | 'disabled'>('all')
|
|
|
|
|
const filterStatus = ref<string>('all')
|
|
|
|
|
|
|
|
|
|
const upgradeCount = computed(() => modules.value.filter(m => m.needsUpgrade).length)
|
|
|
|
|
|
|
|
|
|
const statusFilters = computed(() => [
|
|
|
|
|
{ title: 'All', value: 'all' as const, count: modules.value.length },
|
|
|
|
|
{ title: 'Installed', value: 'installed' as const, count: modules.value.filter(m => m.installed).length },
|
|
|
|
|
{ title: 'Not Installed', value: 'not-installed' as const, count: modules.value.filter(m => !m.installed).length },
|
|
|
|
|
{ title: 'Enabled', value: 'enabled' as const, count: modules.value.filter(m => m.enabled).length },
|
|
|
|
|
{ title: 'Disabled', value: 'disabled' as const, count: modules.value.filter(m => m.installed && !m.enabled).length },
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
const filteredModules = computed(() => {
|
|
|
|
|
let result = modules.value
|
|
|
|
|
|
|
|
|
|
// Filter by status
|
|
|
|
|
if (filterStatus.value === 'installed') {
|
|
|
|
|
const status = filterStatus.value
|
|
|
|
|
if (status === 'installed') {
|
|
|
|
|
result = result.filter(m => m.installed)
|
|
|
|
|
} else if (filterStatus.value === 'not-installed') {
|
|
|
|
|
} else if (status === 'not-installed') {
|
|
|
|
|
result = result.filter(m => !m.installed)
|
|
|
|
|
} else if (filterStatus.value === 'enabled') {
|
|
|
|
|
} else if (status === 'enabled') {
|
|
|
|
|
result = result.filter(m => m.enabled)
|
|
|
|
|
} else if (filterStatus.value === 'disabled') {
|
|
|
|
|
} else if (status === 'disabled') {
|
|
|
|
|
result = result.filter(m => m.installed && !m.enabled)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Filter by search
|
|
|
|
|
if (search.value) {
|
|
|
|
|
const searchLower = search.value.toLowerCase()
|
|
|
|
|
const q = (search.value ?? '').trim().toLowerCase()
|
|
|
|
|
if (q) {
|
|
|
|
|
result = result.filter(
|
|
|
|
|
m =>
|
|
|
|
|
m.handle.toLowerCase().includes(searchLower) ||
|
|
|
|
|
m.label.toLowerCase().includes(searchLower) ||
|
|
|
|
|
m.description.toLowerCase().includes(searchLower) ||
|
|
|
|
|
m.author.toLowerCase().includes(searchLower)
|
|
|
|
|
(m.handle ?? '').toLowerCase().includes(q) ||
|
|
|
|
|
(m.label ?? '').toLowerCase().includes(q) ||
|
|
|
|
|
(m.description ?? '').toLowerCase().includes(q) ||
|
|
|
|
|
(m.author ?? '').toLowerCase().includes(q) ||
|
|
|
|
|
(m.namespace ?? '').toLowerCase().includes(q)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -107,166 +110,191 @@ onMounted(() => {
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
<v-container fluid>
|
|
|
|
|
<v-row>
|
|
|
|
|
<v-col cols="12">
|
|
|
|
|
<v-card>
|
|
|
|
|
<v-card-title class="d-flex align-center">
|
|
|
|
|
<v-icon :icon="mdiPackageVariant" class="mr-2"></v-icon>
|
|
|
|
|
<span>Module Manager</span>
|
|
|
|
|
<v-container fluid class="pa-4">
|
|
|
|
|
<!-- Header -->
|
|
|
|
|
<div class="d-flex align-center mb-6">
|
|
|
|
|
<div class="d-flex align-center ga-3">
|
|
|
|
|
<v-avatar color="primary" rounded="lg" size="44">
|
|
|
|
|
<v-icon icon="mdi-package-variant" color="white"></v-icon>
|
|
|
|
|
</v-avatar>
|
|
|
|
|
<div>
|
|
|
|
|
<h1 class="text-h5 font-weight-bold">Module Manager</h1>
|
|
|
|
|
<p class="text-caption text-medium-emphasis mb-0">
|
|
|
|
|
{{ modules.length }} module{{ modules.length !== 1 ? 's' : '' }} available
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<v-spacer></v-spacer>
|
|
|
|
|
<v-btn
|
|
|
|
|
color="primary"
|
|
|
|
|
variant="text"
|
|
|
|
|
icon="mdi-refresh"
|
|
|
|
|
variant="tonal"
|
|
|
|
|
:loading="loading"
|
|
|
|
|
@click="loadModules"
|
|
|
|
|
>
|
|
|
|
|
Refresh
|
|
|
|
|
</v-btn>
|
|
|
|
|
</v-card-title>
|
|
|
|
|
></v-btn>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<v-card-text>
|
|
|
|
|
<v-row class="mb-4">
|
|
|
|
|
<v-col cols="12" md="8">
|
|
|
|
|
<!-- Filters -->
|
|
|
|
|
<div class="d-flex align-center ga-3 flex-wrap mb-4">
|
|
|
|
|
<v-text-field
|
|
|
|
|
v-model="search"
|
|
|
|
|
label="Search modules"
|
|
|
|
|
prepend-inner-icon="mdi-magnify"
|
|
|
|
|
label="Search modules"
|
|
|
|
|
variant="outlined"
|
|
|
|
|
density="compact"
|
|
|
|
|
clearable
|
|
|
|
|
hide-details
|
|
|
|
|
style="min-width: 220px; max-width: 360px;"
|
|
|
|
|
></v-text-field>
|
|
|
|
|
</v-col>
|
|
|
|
|
<v-col cols="12" md="4">
|
|
|
|
|
<v-select
|
|
|
|
|
v-model="filterStatus"
|
|
|
|
|
:items="[
|
|
|
|
|
{ title: 'All Modules', value: 'all' },
|
|
|
|
|
{ title: 'Installed', value: 'installed' },
|
|
|
|
|
{ title: 'Not Installed', value: 'not-installed' },
|
|
|
|
|
{ title: 'Enabled', value: 'enabled' },
|
|
|
|
|
{ title: 'Disabled', value: 'disabled' },
|
|
|
|
|
]"
|
|
|
|
|
label="Filter by status"
|
|
|
|
|
variant="outlined"
|
|
|
|
|
density="compact"
|
|
|
|
|
hide-details
|
|
|
|
|
></v-select>
|
|
|
|
|
</v-col>
|
|
|
|
|
</v-row>
|
|
|
|
|
|
|
|
|
|
<v-alert v-if="error" type="error" class="mb-4">
|
|
|
|
|
<v-btn-toggle
|
|
|
|
|
v-model="filterStatus"
|
|
|
|
|
density="compact"
|
|
|
|
|
variant="outlined"
|
|
|
|
|
color="primary"
|
|
|
|
|
rounded="lg"
|
|
|
|
|
mandatory
|
|
|
|
|
>
|
|
|
|
|
<v-btn
|
|
|
|
|
v-for="filter in statusFilters"
|
|
|
|
|
:key="filter.value"
|
|
|
|
|
:value="filter.value"
|
|
|
|
|
size="small"
|
|
|
|
|
>
|
|
|
|
|
{{ filter.title }} ({{ filter.count }})
|
|
|
|
|
</v-btn>
|
|
|
|
|
</v-btn-toggle>
|
|
|
|
|
|
|
|
|
|
<v-spacer></v-spacer>
|
|
|
|
|
|
|
|
|
|
<v-chip v-if="upgradeCount > 0" color="warning" variant="flat" size="small" prepend-icon="mdi-alert-circle">
|
|
|
|
|
{{ upgradeCount }} upgrade{{ upgradeCount !== 1 ? 's' : '' }} available
|
|
|
|
|
</v-chip>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Error -->
|
|
|
|
|
<v-alert v-if="error" type="error" variant="tonal" class="mb-4" prepend-icon="mdi-alert-circle">
|
|
|
|
|
{{ error }}
|
|
|
|
|
</v-alert>
|
|
|
|
|
|
|
|
|
|
<v-progress-linear v-if="loading" indeterminate color="primary"></v-progress-linear>
|
|
|
|
|
<!-- Loading -->
|
|
|
|
|
<v-progress-linear v-if="loading" indeterminate color="primary" class="mb-4" rounded></v-progress-linear>
|
|
|
|
|
|
|
|
|
|
<!-- Module Grid -->
|
|
|
|
|
<v-row v-else>
|
|
|
|
|
<v-col
|
|
|
|
|
v-for="module in filteredModules"
|
|
|
|
|
:key="module.handle"
|
|
|
|
|
cols="12"
|
|
|
|
|
md="6"
|
|
|
|
|
lg="4"
|
|
|
|
|
xl="4"
|
|
|
|
|
>
|
|
|
|
|
<v-card elevation="2" class="module-card">
|
|
|
|
|
<v-card-title class="d-flex align-center">
|
|
|
|
|
<v-icon :icon="mdiPackageVariant" class="mr-2"></v-icon>
|
|
|
|
|
<span>{{ module.label }}</span>
|
|
|
|
|
<v-spacer></v-spacer>
|
|
|
|
|
<v-chip
|
|
|
|
|
<v-card
|
|
|
|
|
:class="['module-card', { 'module-card--upgrade': module.needsUpgrade }]"
|
|
|
|
|
:border="module.needsUpgrade ? 'warning md' : true"
|
|
|
|
|
rounded="lg"
|
|
|
|
|
>
|
|
|
|
|
<!-- Card Header -->
|
|
|
|
|
<div class="module-card__header pa-4 d-flex align-start ga-3">
|
|
|
|
|
|
|
|
|
|
<!-- Left: text content -->
|
|
|
|
|
<div class="flex-grow-1 min-width-0">
|
|
|
|
|
<div class="d-flex justify-start align-center ga-2 flex-wrap mb-1">
|
|
|
|
|
<v-avatar
|
|
|
|
|
:color="getStatusColor(module)"
|
|
|
|
|
size="small"
|
|
|
|
|
variant="flat"
|
|
|
|
|
rounded="md"
|
|
|
|
|
size="28"
|
|
|
|
|
class="flex-shrink-0"
|
|
|
|
|
>
|
|
|
|
|
{{ getStatusText(module) }}
|
|
|
|
|
<v-icon icon="mdi-package-variant" size="16" color="white"></v-icon>
|
|
|
|
|
</v-avatar>
|
|
|
|
|
<span class="text-subtitle-1 font-weight-semibold text-truncate">{{ module.label || module.handle }}</span>
|
|
|
|
|
<span class="text-caption text-medium-emphasis flex-shrink-0">v{{ module.version }}</span>
|
|
|
|
|
<v-chip
|
|
|
|
|
v-if="module.needsUpgrade"
|
|
|
|
|
color="warning"
|
|
|
|
|
size="x-small"
|
|
|
|
|
variant="flat"
|
|
|
|
|
class="flex-shrink-0"
|
|
|
|
|
prepend-icon="mdi-update"
|
|
|
|
|
>
|
|
|
|
|
Update available
|
|
|
|
|
</v-chip>
|
|
|
|
|
</v-card-title>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="d-flex justify-start align-center ga-1 text-caption text-medium-emphasis mb-2">
|
|
|
|
|
<v-icon icon="mdi-account" size="13"></v-icon>
|
|
|
|
|
<span>{{ module.author || 'Unknown' }}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<p class="text-body-1 mb-0 module-description">{{ module.description || 'No description provided.' }}</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<v-card-subtitle>
|
|
|
|
|
<div class="text-caption">{{ module.handle }}</div>
|
|
|
|
|
<div class="text-caption text-grey">by {{ module.author }} • v{{ module.version }}</div>
|
|
|
|
|
</v-card-subtitle>
|
|
|
|
|
|
|
|
|
|
<v-card-text>
|
|
|
|
|
<p class="module-description">{{ module.description }}</p>
|
|
|
|
|
</v-card-text>
|
|
|
|
|
|
|
|
|
|
<v-card-actions>
|
|
|
|
|
<!-- Right: action buttons stacked -->
|
|
|
|
|
<div class="d-flex flex-column ga-1 flex-shrink-0">
|
|
|
|
|
<v-btn
|
|
|
|
|
v-if="!module.installed"
|
|
|
|
|
color="primary"
|
|
|
|
|
variant="tonal"
|
|
|
|
|
size="small"
|
|
|
|
|
prepend-icon="mdi-download"
|
|
|
|
|
:loading="isActionLoading(module, 'install')"
|
|
|
|
|
@click="handleAction(module, 'install')"
|
|
|
|
|
>
|
|
|
|
|
<v-icon :icon="mdiDownload" start></v-icon>
|
|
|
|
|
Install
|
|
|
|
|
</v-btn>
|
|
|
|
|
|
|
|
|
|
<template v-else>
|
|
|
|
|
<template v-if="module.installed">
|
|
|
|
|
<v-btn
|
|
|
|
|
v-if="module.enabled"
|
|
|
|
|
color="warning"
|
|
|
|
|
variant="tonal"
|
|
|
|
|
size="small"
|
|
|
|
|
:loading="isActionLoading(module, 'disable')"
|
|
|
|
|
@click="handleAction(module, 'disable')"
|
|
|
|
|
>
|
|
|
|
|
<v-icon :icon="mdiCloseCircle" start></v-icon>
|
|
|
|
|
Disable
|
|
|
|
|
</v-btn>
|
|
|
|
|
|
|
|
|
|
<v-btn
|
|
|
|
|
v-else
|
|
|
|
|
v-if="!module.enabled"
|
|
|
|
|
color="success"
|
|
|
|
|
variant="tonal"
|
|
|
|
|
size="small"
|
|
|
|
|
prepend-icon="mdi-check-circle"
|
|
|
|
|
:loading="isActionLoading(module, 'enable')"
|
|
|
|
|
@click="handleAction(module, 'enable')"
|
|
|
|
|
>
|
|
|
|
|
<v-icon :icon="mdiCheckCircle" start></v-icon>
|
|
|
|
|
Enable
|
|
|
|
|
</v-btn>
|
|
|
|
|
|
|
|
|
|
<v-btn
|
|
|
|
|
color="info"
|
|
|
|
|
v-if="module.enabled"
|
|
|
|
|
color="warning"
|
|
|
|
|
variant="tonal"
|
|
|
|
|
size="small"
|
|
|
|
|
prepend-icon="mdi-close-circle"
|
|
|
|
|
:loading="isActionLoading(module, 'disable')"
|
|
|
|
|
@click="handleAction(module, 'disable')"
|
|
|
|
|
>
|
|
|
|
|
Disable
|
|
|
|
|
</v-btn>
|
|
|
|
|
|
|
|
|
|
<v-btn
|
|
|
|
|
v-if="module.needsUpgrade"
|
|
|
|
|
color="warning"
|
|
|
|
|
variant="tonal"
|
|
|
|
|
prepend-icon="mdi-update"
|
|
|
|
|
:loading="isActionLoading(module, 'upgrade')"
|
|
|
|
|
@click="handleAction(module, 'upgrade')"
|
|
|
|
|
>
|
|
|
|
|
<v-icon :icon="mdiUpdate" start></v-icon>
|
|
|
|
|
Upgrade
|
|
|
|
|
</v-btn>
|
|
|
|
|
|
|
|
|
|
<v-spacer></v-spacer>
|
|
|
|
|
|
|
|
|
|
<v-btn
|
|
|
|
|
color="error"
|
|
|
|
|
variant="tonal"
|
|
|
|
|
size="small"
|
|
|
|
|
prepend-icon="mdi-trash-can"
|
|
|
|
|
:loading="isActionLoading(module, 'uninstall')"
|
|
|
|
|
@click="handleAction(module, 'uninstall')"
|
|
|
|
|
>
|
|
|
|
|
<v-icon :icon="mdiTrashCan" start></v-icon>
|
|
|
|
|
Uninstall
|
|
|
|
|
</v-btn>
|
|
|
|
|
</template>
|
|
|
|
|
</v-card-actions>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</v-card>
|
|
|
|
|
</v-col>
|
|
|
|
|
|
|
|
|
|
<v-col v-if="filteredModules.length === 0" cols="12">
|
|
|
|
|
<v-alert type="info">
|
|
|
|
|
No modules found matching your criteria.
|
|
|
|
|
</v-alert>
|
|
|
|
|
</v-col>
|
|
|
|
|
</v-row>
|
|
|
|
|
</v-card-text>
|
|
|
|
|
</v-card>
|
|
|
|
|
<v-col v-if="filteredModules.length === 0 && !loading" cols="12">
|
|
|
|
|
<v-empty-state
|
|
|
|
|
icon="mdi-package-variant"
|
|
|
|
|
title="No modules found"
|
|
|
|
|
text="Try adjusting your search or filter criteria."
|
|
|
|
|
></v-empty-state>
|
|
|
|
|
</v-col>
|
|
|
|
|
</v-row>
|
|
|
|
|
|
|
|
|
|
@@ -274,19 +302,29 @@ onMounted(() => {
|
|
|
|
|
v-model="snackbar"
|
|
|
|
|
:color="snackbarColor"
|
|
|
|
|
:timeout="3000"
|
|
|
|
|
location="bottom right"
|
|
|
|
|
>
|
|
|
|
|
{{ snackbarText }}
|
|
|
|
|
<template #actions>
|
|
|
|
|
<v-btn variant="text" @click="snackbar = false">Close</v-btn>
|
|
|
|
|
</template>
|
|
|
|
|
</v-snackbar>
|
|
|
|
|
</v-container>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<style scoped lang="scss">
|
|
|
|
|
.module-card {
|
|
|
|
|
height: 100%;
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
height: 100%;
|
|
|
|
|
transition: box-shadow 0.2s ease;
|
|
|
|
|
|
|
|
|
|
.v-card-text {
|
|
|
|
|
&:hover {
|
|
|
|
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12) !important;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
&__header {
|
|
|
|
|
border-bottom: none;
|
|
|
|
|
flex: 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -296,7 +334,14 @@ onMounted(() => {
|
|
|
|
|
-webkit-box-orient: vertical;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
min-height: 4.5em;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.cursor-pointer {
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.min-width-0 {
|
|
|
|
|
min-width: 0;
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
|